From e72cf0acef40abacce15969ada5d9fe2794b39bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 10:21:35 +0000 Subject: [PATCH 1/2] M4/M5 part 1: AI providers, usage and Settings The assistant's foundations, copied from GAM's M6 (which came from Notes): per-user AI providers with encrypted keys, the SSRF guard, the retrying upstream call, usage events and list-price pricing (Claude table brought up to date), and a Settings page with the provider panel and spend. New over GAM: the clients are multi-turn and call tools. Each keeps its history in the provider's own wire format and only appends to it, so an Anthropic turn's content (thinking blocks included) is replayed exactly. Ollama is served over its OpenAI-compatible /v1 endpoint, tools and all. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016fiRJAt34ag62iPiuMkNhm --- ..._add_ai_provider_and_usage_event_tables.py | 87 +++ backend/app/main.py | 4 + backend/app/models/__init__.py | 4 +- backend/app/models/provider.py | 74 +++ backend/app/models/usage.py | 62 ++ backend/app/providers/__init__.py | 119 ++++ backend/app/providers/_upstream.py | 172 ++++++ backend/app/providers/anthropic.py | 170 +++++ backend/app/providers/base.py | 100 +++ backend/app/providers/endpoints.py | 89 +++ backend/app/providers/openai.py | 152 +++++ backend/app/providers/params.py | 45 ++ backend/app/routers/providers.py | 360 +++++++++++ backend/app/routers/usage.py | 62 ++ backend/app/safe_url.py | 81 +++ backend/app/schemas_providers.py | 105 ++++ backend/app/usage/__init__.py | 1 + backend/app/usage/events.py | 86 +++ backend/app/usage/pricing.py | 100 +++ backend/tests/test_build_provider.py | 141 +++++ backend/tests/test_provider_conversations.py | 197 ++++++ backend/tests/test_provider_upstream.py | 219 +++++++ backend/tests/test_providers_api.py | 537 ++++++++++++++++ backend/tests/test_safe_url.py | 143 +++++ backend/tests/test_usage.py | 58 ++ frontend/src/App.tsx | 2 + frontend/src/api/providers.ts | 108 ++++ frontend/src/api/usage.ts | 58 ++ frontend/src/components/AppShell.tsx | 10 +- .../src/components/ProviderPanel.test.tsx | 242 ++++++++ frontend/src/components/ProviderPanel.tsx | 579 ++++++++++++++++++ frontend/src/components/UsagePanel.test.tsx | 36 ++ frontend/src/components/UsagePanel.tsx | 78 +++ frontend/src/utils/useSavedFlash.ts | 19 + frontend/src/views/SettingsView.tsx | 13 + 35 files changed, 4311 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/20261016_1016_add_ai_provider_and_usage_event_tables.py create mode 100644 backend/app/models/provider.py create mode 100644 backend/app/models/usage.py create mode 100644 backend/app/providers/__init__.py create mode 100644 backend/app/providers/_upstream.py create mode 100644 backend/app/providers/anthropic.py create mode 100644 backend/app/providers/base.py create mode 100644 backend/app/providers/endpoints.py create mode 100644 backend/app/providers/openai.py create mode 100644 backend/app/providers/params.py create mode 100644 backend/app/routers/providers.py create mode 100644 backend/app/routers/usage.py create mode 100644 backend/app/safe_url.py create mode 100644 backend/app/schemas_providers.py create mode 100644 backend/app/usage/__init__.py create mode 100644 backend/app/usage/events.py create mode 100644 backend/app/usage/pricing.py create mode 100644 backend/tests/test_build_provider.py create mode 100644 backend/tests/test_provider_conversations.py create mode 100644 backend/tests/test_provider_upstream.py create mode 100644 backend/tests/test_providers_api.py create mode 100644 backend/tests/test_safe_url.py create mode 100644 backend/tests/test_usage.py create mode 100644 frontend/src/api/providers.ts create mode 100644 frontend/src/api/usage.ts create mode 100644 frontend/src/components/ProviderPanel.test.tsx create mode 100644 frontend/src/components/ProviderPanel.tsx create mode 100644 frontend/src/components/UsagePanel.test.tsx create mode 100644 frontend/src/components/UsagePanel.tsx create mode 100644 frontend/src/utils/useSavedFlash.ts create mode 100644 frontend/src/views/SettingsView.tsx diff --git a/backend/alembic/versions/20261016_1016_add_ai_provider_and_usage_event_tables.py b/backend/alembic/versions/20261016_1016_add_ai_provider_and_usage_event_tables.py new file mode 100644 index 0000000..e57170c --- /dev/null +++ b/backend/alembic/versions/20261016_1016_add_ai_provider_and_usage_event_tables.py @@ -0,0 +1,87 @@ +"""Add the aiprovider and usageevent tables (M4/M5, the assistant's first half). + +Both are copied from GAM's, which came from gecko-notes. `usageevent.project_id` and +`turn_id` are plain indexed columns, not foreign keys: deleting a project must not +delete the record of what was spent on it. + +Revision ID: 4a8c2f6e9d13 +Revises: 7e2d5a91b3c6 +Create Date: 2026-10-16 10:16:48.213291+00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = '4a8c2f6e9d13' +down_revision: Union[str, None] = '7e2d5a91b3c6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('aiprovider', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('provider_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('api_key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('base_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('model', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('max_tokens', sa.Integer(), nullable=False), + sa.Column('supports_images', sa.Boolean(), nullable=False), + sa.Column('use_anthropic_api', sa.Boolean(), nullable=False), + sa.Column('extra_params', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('aiprovider', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_aiprovider_user_id'), ['user_id'], unique=False) + + op.create_table('usageevent', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('user_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('project_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('kind', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('model', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('units', sa.Integer(), nullable=False), + sa.Column('unit_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('cost', sa.Float(), nullable=True), + sa.Column('currency', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('cost_estimated', sa.Boolean(), nullable=True), + sa.Column('turn_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('usageevent', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_usageevent_created_at'), ['created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_usageevent_kind'), ['kind'], unique=False) + batch_op.create_index(batch_op.f('ix_usageevent_project_id'), ['project_id'], unique=False) + batch_op.create_index(batch_op.f('ix_usageevent_provider'), ['provider'], unique=False) + batch_op.create_index(batch_op.f('ix_usageevent_turn_id'), ['turn_id'], unique=False) + batch_op.create_index('ix_usageevent_user_created', ['user_id', 'created_at'], unique=False) + batch_op.create_index(batch_op.f('ix_usageevent_user_id'), ['user_id'], unique=False) + + + +def downgrade() -> None: + with op.batch_alter_table('usageevent', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_usageevent_user_id')) + batch_op.drop_index('ix_usageevent_user_created') + batch_op.drop_index(batch_op.f('ix_usageevent_turn_id')) + batch_op.drop_index(batch_op.f('ix_usageevent_provider')) + batch_op.drop_index(batch_op.f('ix_usageevent_project_id')) + batch_op.drop_index(batch_op.f('ix_usageevent_kind')) + batch_op.drop_index(batch_op.f('ix_usageevent_created_at')) + + op.drop_table('usageevent') + with op.batch_alter_table('aiprovider', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_aiprovider_user_id')) + + op.drop_table('aiprovider') diff --git a/backend/app/main.py b/backend/app/main.py index 6d957b4..0d0e76f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -22,8 +22,10 @@ from app.gam.client import GamError from app.routers import gam as gam_router from app.routers import projects as projects_router +from app.routers import providers as providers_router from app.routers import renders as renders_router from app.routers import spec as spec_router +from app.routers import usage as usage_router from app.schemas import ClientConfig, DataResponse, HealthResponse logging.basicConfig(level=logging.INFO) @@ -145,5 +147,7 @@ def me(user: CurrentUser, session: Session = Depends(get_session)) -> DataRespon app.include_router(spec_router.router, prefix="/api/spec", tags=["spec"]) app.include_router(gam_router.router, prefix="/api/gam", tags=["gam"]) app.include_router(renders_router.router, prefix="/api", tags=["renders"]) +app.include_router(providers_router.router, prefix="/api/providers", tags=["providers"]) +app.include_router(usage_router.router, prefix="/api/usage", tags=["usage"]) # Not under /api: these URLs go straight into