From 0bbcacd1868f7f952afe647daf57bd532074ccc2 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sun, 25 Jan 2026 22:18:39 +0800 Subject: [PATCH 1/4] fix: allow multiple provider keys per base url Use a composite unique constraint and query filters so providers can share base URLs with distinct API keys. --- ...stream_provider_base_url_api_key_unique.py | 50 +++++++++++++++++++ routstr/core/admin.py | 6 ++- routstr/core/db.py | 6 ++- routstr/upstream/helpers.py | 35 +++++++------ routstr/upstream/ppqai.py | 4 +- 5 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py diff --git a/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py b/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py new file mode 100644 index 00000000..83035eb0 --- /dev/null +++ b/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py @@ -0,0 +1,50 @@ +"""make upstream provider base_url + api_key unique + +Revision ID: c2d3e4f5a6b7 +Revises: a86e5348850b +Create Date: 2026-01-25 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "c2d3e4f5a6b7" +down_revision = "a86e5348850b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("upstream_providers", recreate="always") as batch_op: + batch_op.drop_index("ix_upstream_providers_base_url") + batch_op.alter_column( + "base_url", + existing_type=sa.String(), + nullable=False, + unique=False, + ) + batch_op.create_unique_constraint( + "uq_upstream_providers_base_url_api_key", + ["base_url", "api_key"], + ) + + +def downgrade() -> None: + with op.batch_alter_table("upstream_providers", recreate="always") as batch_op: + batch_op.drop_constraint( + "uq_upstream_providers_base_url_api_key", + type_="unique", + ) + batch_op.alter_column( + "base_url", + existing_type=sa.String(), + nullable=False, + unique=True, + ) + batch_op.create_index( + "ix_upstream_providers_base_url", + ["base_url"], + unique=True, + ) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 626df30c..783474a8 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -2598,12 +2598,14 @@ async def create_upstream_provider( async with create_session() as session: result = await session.exec( select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == payload.base_url + UpstreamProviderRow.base_url == payload.base_url, + UpstreamProviderRow.api_key == payload.api_key, ) ) if result.first(): raise HTTPException( - status_code=409, detail="Provider with this base URL already exists" + status_code=409, + detail="Provider with this base URL and API key already exists", ) provider = UpstreamProviderRow( diff --git a/routstr/core/db.py b/routstr/core/db.py index 46c56582..bbcfc6fe 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -5,6 +5,7 @@ from typing import AsyncGenerator from alembic import command from alembic.config import Config +from sqlalchemy import UniqueConstraint from sqlalchemy.ext.asyncio.engine import create_async_engine from sqlmodel import Field, Relationship, SQLModel, func, select, update from sqlmodel.ext.asyncio.session import AsyncSession @@ -111,11 +112,14 @@ class LightningInvoice(SQLModel, table=True): # type: ignore class UpstreamProviderRow(SQLModel, table=True): # type: ignore __tablename__ = "upstream_providers" + __table_args__ = ( + UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"), + ) id: int | None = Field(default=None, primary_key=True) provider_type: str = Field( description="Provider type: custom, openai, anthropic, azure, openrouter, etc." ) - base_url: str = Field(unique=True, description="Base URL of the upstream API") + base_url: str = Field(description="Base URL of the upstream API") api_key: str = Field(description="API key for the upstream provider") api_version: str | None = Field( default=None, description="API version for Azure OpenAI" diff --git a/routstr/upstream/helpers.py b/routstr/upstream/helpers.py index 29e099ca..95b6ca84 100644 --- a/routstr/upstream/helpers.py +++ b/routstr/upstream/helpers.py @@ -236,7 +236,7 @@ async def _seed_providers_from_settings( from . import upstream_provider_classes providers_to_add: list[UpstreamProviderRow] = [] - seeded_base_urls: set[str] = set() + seeded_provider_keys: set[tuple[str, str]] = set() provider_classes_by_type = { cls.provider_type: cls @@ -261,7 +261,8 @@ async def _seed_providers_from_settings( base_url = provider_class.default_base_url # type: ignore[attr-defined] result = await session.exec( select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == base_url + UpstreamProviderRow.base_url == base_url, + UpstreamProviderRow.api_key == api_key, ) ) if not result.first(): @@ -273,13 +274,15 @@ async def _seed_providers_from_settings( enabled=True, ) ) - seeded_base_urls.add(base_url) + seeded_provider_keys.add((base_url, api_key)) ollama_base_url = os.environ.get("OLLAMA_BASE_URL") if ollama_base_url: + ollama_api_key = os.environ.get("OLLAMA_API_KEY", "") result = await session.exec( select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == ollama_base_url + UpstreamProviderRow.base_url == ollama_base_url, + UpstreamProviderRow.api_key == ollama_api_key, ) ) if not result.first(): @@ -287,18 +290,20 @@ async def _seed_providers_from_settings( UpstreamProviderRow( provider_type="ollama", base_url=ollama_base_url, - api_key=os.environ.get("OLLAMA_API_KEY", ""), + api_key=ollama_api_key, enabled=True, ) ) - seeded_base_urls.add(ollama_base_url) + seeded_provider_keys.add((ollama_base_url, ollama_api_key)) if settings.chat_completions_api_version and settings.upstream_base_url: base_url = settings.upstream_base_url - if base_url not in seeded_base_urls: + api_key = settings.upstream_api_key + if (base_url, api_key) not in seeded_provider_keys: result = await session.exec( select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == base_url + UpstreamProviderRow.base_url == base_url, + UpstreamProviderRow.api_key == api_key, ) ) if not result.first(): @@ -306,19 +311,21 @@ async def _seed_providers_from_settings( UpstreamProviderRow( provider_type="azure", base_url=base_url, - api_key=settings.upstream_api_key, + api_key=api_key, api_version=settings.chat_completions_api_version, enabled=True, ) ) - seeded_base_urls.add(base_url) + seeded_provider_keys.add((base_url, api_key)) if settings.upstream_base_url and settings.upstream_api_key: base_url = settings.upstream_base_url - if base_url not in seeded_base_urls: + api_key = settings.upstream_api_key + if (base_url, api_key) not in seeded_provider_keys: result = await session.exec( select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == base_url + UpstreamProviderRow.base_url == base_url, + UpstreamProviderRow.api_key == api_key, ) ) if not result.first(): @@ -326,11 +333,11 @@ async def _seed_providers_from_settings( UpstreamProviderRow( provider_type="custom", base_url=base_url, - api_key=settings.upstream_api_key, + api_key=api_key, enabled=True, ) ) - seeded_base_urls.add(base_url) + seeded_provider_keys.add((base_url, api_key)) for provider in providers_to_add: session.add(provider) diff --git a/routstr/upstream/ppqai.py b/routstr/upstream/ppqai.py index 01a5decb..c8e9f6a4 100644 --- a/routstr/upstream/ppqai.py +++ b/routstr/upstream/ppqai.py @@ -210,8 +210,8 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider): async with create_session() as session: statement = select(UpstreamProviderRow).where( - UpstreamProviderRow.base_url == self.base_url - and UpstreamProviderRow.api_key == self.api_key + UpstreamProviderRow.base_url == self.base_url, + UpstreamProviderRow.api_key == self.api_key, ) result = await session.exec(statement) provider = result.first() From 788075f656ac7f2d955264b9cb76baf21ab5fa21 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sun, 25 Jan 2026 22:44:13 +0800 Subject: [PATCH 2/4] fix db migration --- ...7b8c9_remove_base_url_unique_constraint.py | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 migrations/versions/d4e5f6a7b8c9_remove_base_url_unique_constraint.py diff --git a/migrations/versions/d4e5f6a7b8c9_remove_base_url_unique_constraint.py b/migrations/versions/d4e5f6a7b8c9_remove_base_url_unique_constraint.py new file mode 100644 index 00000000..31546192 --- /dev/null +++ b/migrations/versions/d4e5f6a7b8c9_remove_base_url_unique_constraint.py @@ -0,0 +1,82 @@ +"""remove upstream_providers base_url unique constraint + +Revision ID: d4e5f6a7b8c9 +Revises: c2d3e4f5a6b7 +Create Date: 2026-01-25 00:00:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "d4e5f6a7b8c9" +down_revision = "c2d3e4f5a6b7" +branch_labels = None +depends_on = None + + +def _recreate_table_sqlite(add_base_url_unique: bool) -> None: + constraints = [ + sa.UniqueConstraint( + "base_url", + "api_key", + name="uq_upstream_providers_base_url_api_key", + ) + ] + if add_base_url_unique: + constraints.append( + sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url") + ) + op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old") + op.create_table( + "upstream_providers", + sa.Column( + "id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True + ), + sa.Column("provider_type", sa.String(), nullable=False), + sa.Column("base_url", sa.String(), nullable=False), + sa.Column("api_key", sa.String(), nullable=False), + sa.Column("api_version", sa.String(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"), + *constraints, + ) + op.execute( + "INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) " + "SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee " + "FROM upstream_providers_old" + ) + op.drop_table("upstream_providers_old") + + +def upgrade() -> None: + conn = op.get_bind() + if conn.dialect.name == "sqlite": + _recreate_table_sqlite(add_base_url_unique=False) + return + + inspector = sa.inspect(conn) + for constraint in inspector.get_unique_constraints("upstream_providers"): + if constraint.get("column_names") == ["base_url"] and constraint.get("name"): + op.drop_constraint( + constraint["name"], + "upstream_providers", + type_="unique", + ) + index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")} + if "ix_upstream_providers_base_url" in index_names: + op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers") + + +def downgrade() -> None: + conn = op.get_bind() + if conn.dialect.name == "sqlite": + _recreate_table_sqlite(add_base_url_unique=True) + return + + op.create_unique_constraint( + "uq_upstream_providers_base_url", + "upstream_providers", + ["base_url"], + ) From 51c3e5dcd7d5036f60a80ea216f71bea708678d4 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Sun, 25 Jan 2026 22:58:36 +0800 Subject: [PATCH 3/4] fix migrations --- ...stream_provider_base_url_api_key_unique.py | 119 ++++++++++++++---- ...7b8c9_remove_base_url_unique_constraint.py | 82 ------------ 2 files changed, 93 insertions(+), 108 deletions(-) delete mode 100644 migrations/versions/d4e5f6a7b8c9_remove_base_url_unique_constraint.py diff --git a/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py b/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py index 83035eb0..bce53175 100644 --- a/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py +++ b/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py @@ -16,35 +16,102 @@ branch_labels = None depends_on = None -def upgrade() -> None: - with op.batch_alter_table("upstream_providers", recreate="always") as batch_op: - batch_op.drop_index("ix_upstream_providers_base_url") - batch_op.alter_column( +def _recreate_table_sqlite(add_base_url_unique: bool) -> None: + conn = op.get_bind() + existing_tables = { + row[0] + for row in conn.exec_driver_sql( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + if "upstream_providers_old" in existing_tables: + if "upstream_providers" in existing_tables: + op.drop_table("upstream_providers_old") + else: + op.execute( + "ALTER TABLE upstream_providers_old RENAME TO upstream_providers" + ) + existing_tables.add("upstream_providers") + if "upstream_providers" not in existing_tables: + return + + constraints = [ + sa.UniqueConstraint( "base_url", - existing_type=sa.String(), - nullable=False, - unique=False, + "api_key", + name="uq_upstream_providers_base_url_api_key", ) - batch_op.create_unique_constraint( - "uq_upstream_providers_base_url_api_key", - ["base_url", "api_key"], + ] + if add_base_url_unique: + constraints.append( + sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url") ) + op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old") + op.create_table( + "upstream_providers", + sa.Column( + "id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True + ), + sa.Column("provider_type", sa.String(), nullable=False), + sa.Column("base_url", sa.String(), nullable=False), + sa.Column("api_key", sa.String(), nullable=False), + sa.Column("api_version", sa.String(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=False), + sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"), + *constraints, + ) + op.execute( + "INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) " + "SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee " + "FROM upstream_providers_old" + ) + op.drop_table("upstream_providers_old") + + +def upgrade() -> None: + conn = op.get_bind() + if conn.dialect.name == "sqlite": + _recreate_table_sqlite(add_base_url_unique=False) + return + + inspector = sa.inspect(conn) + for constraint in inspector.get_unique_constraints("upstream_providers"): + if constraint.get("column_names") == ["base_url"] and constraint.get("name"): + op.drop_constraint( + constraint["name"], + "upstream_providers", + type_="unique", + ) + index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")} + if "ix_upstream_providers_base_url" in index_names: + op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers") + op.create_unique_constraint( + "uq_upstream_providers_base_url_api_key", + "upstream_providers", + ["base_url", "api_key"], + ) + def downgrade() -> None: - with op.batch_alter_table("upstream_providers", recreate="always") as batch_op: - batch_op.drop_constraint( - "uq_upstream_providers_base_url_api_key", - type_="unique", - ) - batch_op.alter_column( - "base_url", - existing_type=sa.String(), - nullable=False, - unique=True, - ) - batch_op.create_index( - "ix_upstream_providers_base_url", - ["base_url"], - unique=True, - ) + conn = op.get_bind() + if conn.dialect.name == "sqlite": + _recreate_table_sqlite(add_base_url_unique=True) + return + + op.drop_constraint( + "uq_upstream_providers_base_url_api_key", + "upstream_providers", + type_="unique", + ) + op.create_unique_constraint( + "uq_upstream_providers_base_url", + "upstream_providers", + ["base_url"], + ) + op.create_index( + "ix_upstream_providers_base_url", + "upstream_providers", + ["base_url"], + unique=True, + ) diff --git a/migrations/versions/d4e5f6a7b8c9_remove_base_url_unique_constraint.py b/migrations/versions/d4e5f6a7b8c9_remove_base_url_unique_constraint.py deleted file mode 100644 index 31546192..00000000 --- a/migrations/versions/d4e5f6a7b8c9_remove_base_url_unique_constraint.py +++ /dev/null @@ -1,82 +0,0 @@ -"""remove upstream_providers base_url unique constraint - -Revision ID: d4e5f6a7b8c9 -Revises: c2d3e4f5a6b7 -Create Date: 2026-01-25 00:00:00.000000 -""" - -from __future__ import annotations - -import sqlalchemy as sa -from alembic import op - -revision = "d4e5f6a7b8c9" -down_revision = "c2d3e4f5a6b7" -branch_labels = None -depends_on = None - - -def _recreate_table_sqlite(add_base_url_unique: bool) -> None: - constraints = [ - sa.UniqueConstraint( - "base_url", - "api_key", - name="uq_upstream_providers_base_url_api_key", - ) - ] - if add_base_url_unique: - constraints.append( - sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url") - ) - op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old") - op.create_table( - "upstream_providers", - sa.Column( - "id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True - ), - sa.Column("provider_type", sa.String(), nullable=False), - sa.Column("base_url", sa.String(), nullable=False), - sa.Column("api_key", sa.String(), nullable=False), - sa.Column("api_version", sa.String(), nullable=True), - sa.Column("enabled", sa.Boolean(), nullable=False), - sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"), - *constraints, - ) - op.execute( - "INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) " - "SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee " - "FROM upstream_providers_old" - ) - op.drop_table("upstream_providers_old") - - -def upgrade() -> None: - conn = op.get_bind() - if conn.dialect.name == "sqlite": - _recreate_table_sqlite(add_base_url_unique=False) - return - - inspector = sa.inspect(conn) - for constraint in inspector.get_unique_constraints("upstream_providers"): - if constraint.get("column_names") == ["base_url"] and constraint.get("name"): - op.drop_constraint( - constraint["name"], - "upstream_providers", - type_="unique", - ) - index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")} - if "ix_upstream_providers_base_url" in index_names: - op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers") - - -def downgrade() -> None: - conn = op.get_bind() - if conn.dialect.name == "sqlite": - _recreate_table_sqlite(add_base_url_unique=True) - return - - op.create_unique_constraint( - "uq_upstream_providers_base_url", - "upstream_providers", - ["base_url"], - ) From 1d043cd98d32f63f4de24e429e4cbf71ddadb82d Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 27 Jan 2026 09:22:07 +0100 Subject: [PATCH 4/4] lint --- ...c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py b/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py index bce53175..5132be66 100644 --- a/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py +++ b/migrations/versions/c2d3e4f5a6b7_upstream_provider_base_url_api_key_unique.py @@ -77,9 +77,10 @@ def upgrade() -> None: inspector = sa.inspect(conn) for constraint in inspector.get_unique_constraints("upstream_providers"): - if constraint.get("column_names") == ["base_url"] and constraint.get("name"): + name = constraint.get("name") + if constraint.get("column_names") == ["base_url"] and name: op.drop_constraint( - constraint["name"], + name, "upstream_providers", type_="unique", )