"""Provider routing: provider_configs + model_assignments. Revision ID: 004_provider_routing Revises: 003_blocker_resolver_type Create Date: 2026-04-21 Adds two tables so agents can be routed per-role / per-agent to different model providers (Anthropic via mounted ~/.claude, Ollama Cloud via ANTHROPIC_BASE_URL+AUTH_TOKEN env injection). - `provider_configs`: one row per logical provider. The Anthropic default is seeded with no base_url / no token — it's a pointer-only row; auth stays in the agent container's mounted ~/.claude. - `model_assignments`: scope (`global` | `role` | `agent_slug`) → (provider, model_name). Unique on (scope, scope_value) with NULLS NOT DISTINCT so the single `global` row can't be duplicated. Zero rows in `model_assignments` leaves every spawn on the legacy `ROLE_MODEL_MAP` path — fully backward-compatible. """ import sqlalchemy as sa from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = "004_provider_routing" down_revision = "003_blocker_resolver_type" branch_labels = None depends_on = None # Enum labels are the StrEnum `.value` (lowercase) — matching the ORM's # `_str_enum` (values_callable=lambda obj: [m.value for m in obj]) and the # lowercase convention set by 001_initial_schema. The Python member NAMES are # uppercase, but the PERSISTED labels must be lowercase or the ORM cannot read # or write these rows (it binds/compares by `.value`). _PROVIDER_TYPES = ("anthropic", "ollama_cloud", "openai", "local") _ASSIGNMENT_SCOPES = ("global", "role", "agent_slug") def upgrade() -> None: # `checkfirst=True` on SA's ENUM creation has been unreliable across # PG versions; use raw `DO $$` + `EXCEPTION duplicate_object` so the # migration is safely re-runnable on a DB that already has the types # from a prior half-applied attempt. op.execute( sa.text( """ DO $$ BEGIN CREATE TYPE modelprovider AS ENUM ( 'anthropic', 'ollama_cloud', 'openai', 'local' ); EXCEPTION WHEN duplicate_object THEN NULL; END $$; """ ) ) op.execute( sa.text( """ DO $$ BEGIN CREATE TYPE assignmentscope AS ENUM ( 'global', 'role', 'agent_slug' ); EXCEPTION WHEN duplicate_object THEN NULL; END $$; """ ) ) # Reference the pre-existing enums from the table columns without # re-emitting their DDL. `create_type=False` is the critical flag. provider_enum = postgresql.ENUM( *_PROVIDER_TYPES, name="modelprovider", create_type=False ) scope_enum = postgresql.ENUM( *_ASSIGNMENT_SCOPES, name="assignmentscope", create_type=False ) # Defensive: if a prior attempt half-created these tables, drop them # clean before re-creating. Enum types survive this (they're owned by # the database, not the tables). op.execute(sa.text("DROP TABLE IF EXISTS model_assignments CASCADE")) op.execute(sa.text("DROP TABLE IF EXISTS provider_configs CASCADE")) op.create_table( "provider_configs", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False, ), sa.Column("name", sa.String(length=100), nullable=False), sa.Column("type", provider_enum, nullable=False), sa.Column("base_url", sa.Text(), nullable=True), sa.Column("auth_token_encrypted", sa.Text(), nullable=True), sa.Column( "enabled", sa.Boolean(), nullable=False, server_default=sa.text("true"), ), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=True, ), sa.UniqueConstraint("name", name="uq_provider_configs_name"), ) op.create_index( "ix_provider_configs_name", "provider_configs", ["name"], unique=False, ) op.create_index( "ix_provider_configs_enabled", "provider_configs", ["enabled"], unique=False, ) op.create_table( "model_assignments", sa.Column( "id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False, ), sa.Column("scope", scope_enum, nullable=False), sa.Column("scope_value", sa.String(length=100), nullable=True), sa.Column( "provider_config_id", postgresql.UUID(as_uuid=True), nullable=False, ), sa.Column("model_name", sa.String(length=100), nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()"), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=True, ), sa.ForeignKeyConstraint( ["provider_config_id"], ["provider_configs.id"], ondelete="RESTRICT", ), ) # NULLS NOT DISTINCT is PG 15+. roboco runs pgvector on PG 16, so fine. # This stops the `global` row (scope_value=NULL) from being duplicated. op.create_index( "ux_model_assignments_scope_key", "model_assignments", ["scope", "scope_value"], unique=True, postgresql_nulls_not_distinct=True, ) op.create_index( "ix_model_assignments_provider", "model_assignments", ["provider_config_id"], unique=False, ) # Seed BOTH providers so the Settings UI is zero-setup: the user never # "creates a provider" — they just pick a mode and (if using Ollama) # paste the API key. # # * Anthropic — pointer-only. base_url + token stay NULL; spawn-time # env injection is skipped and the container uses its mounted # ~/.claude auth just like today. # # * Ollama Cloud — pre-seeded disabled. The key-input endpoint # (`PUT /api/v1/providers/ollama-key`) flips enabled=true and # stores the Fernet-encrypted token when the user saves their key. op.execute( sa.text( """ INSERT INTO provider_configs (id, name, type, base_url, auth_token_encrypted, enabled, created_at) VALUES ( gen_random_uuid(), 'Anthropic (default)', 'anthropic', NULL, NULL, true, now() ), ( gen_random_uuid(), 'Ollama Cloud', 'ollama_cloud', 'https://ollama.com', NULL, false, now() ) """ ) ) def downgrade() -> None: # `if_exists=True` makes the downgrade safe on a partially-applied # schema (e.g., an earlier upgrade that half-succeeded), since # alembic `drop_table` doesn't take a checkfirst flag directly. op.execute("DROP INDEX IF EXISTS ix_model_assignments_provider") op.execute("DROP INDEX IF EXISTS ux_model_assignments_scope_key") op.execute("DROP TABLE IF EXISTS model_assignments") op.execute("DROP INDEX IF EXISTS ix_provider_configs_enabled") op.execute("DROP INDEX IF EXISTS ix_provider_configs_name") op.execute("DROP TABLE IF EXISTS provider_configs") postgresql.ENUM(name="assignmentscope").drop( op.get_bind(), checkfirst=True ) postgresql.ENUM(name="modelprovider").drop(op.get_bind(), checkfirst=True)