diff --git a/backend/app/services/provider_usage.py b/backend/app/services/provider_usage.py index a2049b8..57f54ba 100644 --- a/backend/app/services/provider_usage.py +++ b/backend/app/services/provider_usage.py @@ -952,9 +952,10 @@ async def fetch_provider_usage( return cached if provider == "anthropic": - # Prefer the local Claude Code OAuth token (sk-ant-oat01-...) because it's - # the correct credential type for the oauth/usage endpoint. Fall back to - # the manually-stored session_key only when no local OAuth token exists. + # Auto-detected local OAuth token (sk-ant-oat01-) takes precedence โ€” it is + # the correct credential type for the oauth/usage endpoint and is always + # fresher than a manually stored key. Fall back to session_key only when + # no local token is found (e.g. Pipeline running on a different machine). local_oauth = _read_claude_local_oauth_token() effective_session_key = local_oauth or session_key @@ -994,9 +995,11 @@ async def fetch_provider_usage( provider=provider, account_key=account_key, checked_at=utcnow(), reachable=True, ) - # Overlay subscription windows โ€” auto-detected Codex CLI token takes precedence + # Overlay subscription windows. + # Explicit session_key wins (allows a second account to override auto-detection). + # Fall back to auto-detected Codex CLI token when no key is stored. local_codex = _read_codex_local_token() - effective_codex_key = local_codex or session_key + effective_codex_key = session_key or local_codex if effective_codex_key and result.reachable is not False: sub_windows, plan_label = await _fetch_codex_subscription(effective_codex_key) if sub_windows: diff --git a/backend/migrations/versions/a7aa29cf8a20_sync_model_drift.py b/backend/migrations/versions/a7aa29cf8a20_sync_model_drift.py new file mode 100644 index 0000000..7c61a15 --- /dev/null +++ b/backend/migrations/versions/a7aa29cf8a20_sync_model_drift.py @@ -0,0 +1,100 @@ +"""sync_model_drift + +Revision ID: a7aa29cf8a20 +Revises: a1b2c3d4e5f9 +Create Date: 2026-05-22 01:08:44.078222 + +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a7aa29cf8a20' +down_revision = 'a1b2c3d4e5f9' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(op.f('fk_activity_events_board_id_boards'), 'activity_events', type_='foreignkey') + op.create_foreign_key(None, 'activity_events', 'boards', ['board_id'], ['id']) + op.alter_column('agents', 'gateway_id', + existing_type=sa.UUID(), + nullable=False) + op.drop_index(op.f('ix_agents_openclaw_session_id'), table_name='agents', postgresql_where='(openclaw_session_id IS NOT NULL)') + op.create_index(op.f('ix_agents_openclaw_session_id'), 'agents', ['openclaw_session_id'], unique=False) + op.alter_column('approvals', 'confidence', + existing_type=sa.INTEGER(), + type_=sa.Float(), + existing_nullable=False) + op.drop_index(op.f('ix_board_repository_links_org_id'), table_name='board_repository_links') + op.create_index(op.f('ix_board_repository_links_organization_id'), 'board_repository_links', ['organization_id'], unique=False) + op.drop_constraint(op.f('board_task_custom_fields_organization_id_fkey'), 'board_task_custom_fields', type_='foreignkey') + op.drop_column('board_task_custom_fields', 'organization_id') + op.alter_column('boards', 'description', + existing_type=sa.VARCHAR(), + nullable=False) + op.drop_index(op.f('ix_forgejo_connections_org_id'), table_name='forgejo_connections') + op.create_index(op.f('ix_forgejo_connections_organization_id'), 'forgejo_connections', ['organization_id'], unique=False) + op.drop_index(op.f('ix_forgejo_issues_org_id'), table_name='forgejo_issues') + op.drop_index(op.f('ix_forgejo_issues_repo_id'), table_name='forgejo_issues') + op.create_index(op.f('ix_forgejo_issues_forgejo_issue_number'), 'forgejo_issues', ['forgejo_issue_number'], unique=False) + op.create_index(op.f('ix_forgejo_issues_organization_id'), 'forgejo_issues', ['organization_id'], unique=False) + op.create_index(op.f('ix_forgejo_issues_repository_id'), 'forgejo_issues', ['repository_id'], unique=False) + op.drop_index(op.f('ix_forgejo_repos_conn_id'), table_name='forgejo_repositories') + op.drop_index(op.f('ix_forgejo_repos_org_id'), table_name='forgejo_repositories') + op.create_index(op.f('ix_forgejo_repositories_connection_id'), 'forgejo_repositories', ['connection_id'], unique=False) + op.create_index(op.f('ix_forgejo_repositories_organization_id'), 'forgejo_repositories', ['organization_id'], unique=False) + op.drop_constraint(op.f('uq_organizations_name'), 'organizations', type_='unique') + op.drop_index(op.f('ix_provider_credentials_org_id'), table_name='provider_credentials') + op.drop_constraint(op.f('uq_provider_credentials_org_provider_key'), 'provider_credentials', type_='unique') + op.create_index(op.f('ix_provider_credentials_account_key'), 'provider_credentials', ['account_key'], unique=False) + op.create_index(op.f('ix_provider_credentials_organization_id'), 'provider_credentials', ['organization_id'], unique=False) + op.drop_constraint(op.f('task_custom_field_values_organization_id_fkey'), 'task_custom_field_values', type_='foreignkey') + op.drop_column('task_custom_field_values', 'organization_id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('task_custom_field_values', sa.Column('organization_id', sa.UUID(), autoincrement=False, nullable=False)) + op.create_foreign_key(op.f('task_custom_field_values_organization_id_fkey'), 'task_custom_field_values', 'organizations', ['organization_id'], ['id']) + op.drop_index(op.f('ix_provider_credentials_organization_id'), table_name='provider_credentials') + op.drop_index(op.f('ix_provider_credentials_account_key'), table_name='provider_credentials') + op.create_unique_constraint(op.f('uq_provider_credentials_org_provider_key'), 'provider_credentials', ['organization_id', 'provider', 'account_key'], postgresql_nulls_not_distinct=False) + op.create_index(op.f('ix_provider_credentials_org_id'), 'provider_credentials', ['organization_id'], unique=False) + op.create_unique_constraint(op.f('uq_organizations_name'), 'organizations', ['name'], postgresql_nulls_not_distinct=False) + op.drop_index(op.f('ix_forgejo_repositories_organization_id'), table_name='forgejo_repositories') + op.drop_index(op.f('ix_forgejo_repositories_connection_id'), table_name='forgejo_repositories') + op.create_index(op.f('ix_forgejo_repos_org_id'), 'forgejo_repositories', ['organization_id'], unique=False) + op.create_index(op.f('ix_forgejo_repos_conn_id'), 'forgejo_repositories', ['connection_id'], unique=False) + op.drop_index(op.f('ix_forgejo_issues_repository_id'), table_name='forgejo_issues') + op.drop_index(op.f('ix_forgejo_issues_organization_id'), table_name='forgejo_issues') + op.drop_index(op.f('ix_forgejo_issues_forgejo_issue_number'), table_name='forgejo_issues') + op.create_index(op.f('ix_forgejo_issues_repo_id'), 'forgejo_issues', ['repository_id'], unique=False) + op.create_index(op.f('ix_forgejo_issues_org_id'), 'forgejo_issues', ['organization_id'], unique=False) + op.drop_index(op.f('ix_forgejo_connections_organization_id'), table_name='forgejo_connections') + op.create_index(op.f('ix_forgejo_connections_org_id'), 'forgejo_connections', ['organization_id'], unique=False) + op.alter_column('boards', 'description', + existing_type=sa.VARCHAR(), + nullable=True) + op.add_column('board_task_custom_fields', sa.Column('organization_id', sa.UUID(), autoincrement=False, nullable=False)) + op.create_foreign_key(op.f('board_task_custom_fields_organization_id_fkey'), 'board_task_custom_fields', 'organizations', ['organization_id'], ['id']) + op.drop_index(op.f('ix_board_repository_links_organization_id'), table_name='board_repository_links') + op.create_index(op.f('ix_board_repository_links_org_id'), 'board_repository_links', ['organization_id'], unique=False) + op.alter_column('approvals', 'confidence', + existing_type=sa.Float(), + type_=sa.INTEGER(), + existing_nullable=False) + op.drop_index(op.f('ix_agents_openclaw_session_id'), table_name='agents') + op.create_index(op.f('ix_agents_openclaw_session_id'), 'agents', ['openclaw_session_id'], unique=True, postgresql_where='(openclaw_session_id IS NOT NULL)') + op.alter_column('agents', 'gateway_id', + existing_type=sa.UUID(), + nullable=True) + op.drop_constraint(None, 'activity_events', type_='foreignkey') + op.create_foreign_key(op.f('fk_activity_events_board_id_boards'), 'activity_events', 'boards', ['board_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### diff --git a/backend/tests/test_provider_credentials_usage_api.py b/backend/tests/test_provider_credentials_usage_api.py index 864d9ad..ca8e7b7 100644 --- a/backend/tests/test_provider_credentials_usage_api.py +++ b/backend/tests/test_provider_credentials_usage_api.py @@ -136,6 +136,68 @@ async def test_usage_response_includes_rate_limit_header_names(monkeypatch: pyte await engine.dispose() +@pytest.mark.asyncio +async def test_update_display_name_preserves_stored_tokens() -> None: + engine = await _make_engine() + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + organization = Organization(id=uuid4(), name="Pipeline") + member = OrganizationMember( + id=uuid4(), + organization_id=organization.id, + user_id=uuid4(), + role="owner", + ) + app = _build_test_app( + session_maker, + OrganizationContext(organization=organization, member=member), + ) + + credential = ProviderCredential( + id=uuid4(), + organization_id=organization.id, + provider="anthropic", + account_key="default", + display_name="Claude", + api_key="sk-ant-original", + api_key_last_four="inal", + session_key="sk-ant-session-original", + session_key_last_four="inal", + active=True, + ) + + try: + async with session_maker() as session: + session.add(organization) + session.add(credential) + await session.commit() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.patch( + f"/api/v1/provider-credentials/{credential.id}", + json={"display_name": "Renamed Claude"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["display_name"] == "Renamed Claude" + assert data["has_api_key"] is True + assert data["api_key_last_four"] == "inal" + assert data["has_session_key"] is True + assert data["session_key_last_four"] == "inal" + + async with session_maker() as session: + stored = await session.get(ProviderCredential, credential.id) + assert stored is not None + assert stored.display_name == "Renamed Claude" + assert stored.api_key == "sk-ant-original" + assert stored.session_key == "sk-ant-session-original" + finally: + await engine.dispose() + + @pytest.mark.asyncio async def test_test_endpoint_returns_live_result(monkeypatch: pytest.MonkeyPatch) -> None: engine = await _make_engine() diff --git a/frontend/src/app/settings/ai-providers/page.tsx b/frontend/src/app/settings/ai-providers/page.tsx index 864ab79..170865f 100644 --- a/frontend/src/app/settings/ai-providers/page.tsx +++ b/frontend/src/app/settings/ai-providers/page.tsx @@ -11,6 +11,7 @@ import { HelpCircle, KeyRound, Loader2, + Pencil, Plus, RefreshCw, Server, @@ -190,8 +191,25 @@ function HelpToggle({ // Add/Edit form // --------------------------------------------------------------------------- +type CredentialFormData = { + account_key: string; + display_name: string; + api_key: string; + session_key: string; + base_url: string; +}; + +type ProviderCredentialUpdateWithSession = { + display_name?: string | null; + api_key?: string | null; + session_key?: string | null; + base_url?: string | null; + active?: boolean | null; +}; + interface CredentialFormProps { - providerId: ProviderId; + mode?: "create" | "edit"; + initialCredential?: ProviderCredentialRead; allowMultiple: boolean; showBaseUrl: boolean; showSessionKey: boolean; @@ -203,24 +221,16 @@ interface CredentialFormProps { apiKeyHelp?: readonly string[]; sessionKeyHelp?: readonly string[]; autoDetectedNote?: string; - onSave: (data: { - account_key: string; - display_name: string; - api_key: string; - session_key: string; - base_url: string; - }) => Promise; - onTest: (data: { - account_key: string; - api_key: string; - base_url: string; - }) => Promise; + onSave: (data: CredentialFormData) => Promise; + onTest: (data: CredentialFormData) => Promise; onCancel: () => void; isSaving: boolean; error: string | null; } function CredentialForm({ + mode = "create", + initialCredential, allowMultiple, showBaseUrl, showSessionKey, @@ -238,17 +248,32 @@ function CredentialForm({ isSaving, error, }: CredentialFormProps) { - const [accountKey, setAccountKey] = useState(accountKeyDefault); - const [displayName, setDisplayName] = useState(""); + const isEditMode = mode === "edit"; + const [accountKey, setAccountKey] = useState( + initialCredential?.account_key ?? accountKeyDefault, + ); + const [displayName, setDisplayName] = useState( + initialCredential?.display_name ?? "", + ); const [apiKey, setApiKey] = useState(""); const [sessionKey, setSessionKey] = useState(""); - const [baseUrl, setBaseUrl] = useState(""); + const [baseUrl, setBaseUrl] = useState(initialCredential?.base_url ?? ""); const [isTesting, setIsTesting] = useState(false); const [testResult, setTestResult] = useState(null); const [testError, setTestError] = useState(null); const [showApiHelp, setShowApiHelp] = useState(false); const [showSessionHelp, setShowSessionHelp] = useState(false); - const canTest = showBaseUrl ? Boolean(baseUrl.trim()) : Boolean(apiKey.trim()); + const canTest = showBaseUrl + ? Boolean(baseUrl.trim()) + : Boolean(apiKey.trim() || (showSessionKey && sessionKey.trim())); + const existingApiKeyLabel = + isEditMode && initialCredential?.has_api_key + ? `Stored API key ${initialCredential.api_key_last_four ? `ending ${initialCredential.api_key_last_four}` : "is set"}. Leave blank to keep it.` + : null; + const existingSessionKeyLabel = + isEditMode && initialCredential?.has_session_key + ? `Stored subscription token ${initialCredential.session_key_last_four ? `ending ${initialCredential.session_key_last_four}` : "is set"}. Leave blank to keep it.` + : null; const runTest = async () => { setIsTesting(true); @@ -257,7 +282,9 @@ function CredentialForm({ try { const result = await onTest({ account_key: accountKey.trim() || accountKeyDefault || "test", + display_name: displayName.trim(), api_key: apiKey.trim(), + session_key: sessionKey.trim(), base_url: baseUrl.trim(), }); setTestResult(result); @@ -280,16 +307,18 @@ function CredentialForm({ value={accountKey} onChange={(e) => setAccountKey(e.target.value)} placeholder="e.g. work, personal, gpu-box" - disabled={isSaving} + disabled={isSaving || isEditMode} />

- Used to tell accounts apart in cost reports. + {isEditMode + ? "Account keys are fixed after creation." + : "Used to tell accounts apart in cost reports."}

)}
+ {existingApiKeyLabel && ( +

{existingApiKeyLabel}

+ )} {showApiHelp && !showBaseUrl && apiKeyHelp?.length ? ( ) : null} @@ -372,6 +404,11 @@ function CredentialForm({ disabled={isSaving} autoComplete="new-password" /> + {existingSessionKeyLabel && ( +

+ {existingSessionKeyLabel} +

+ )} {showSessionHelp && sessionKeyHelp?.length ? ( ) : null} @@ -401,8 +438,8 @@ function CredentialForm({ onSave({ account_key: accountKey.trim(), display_name: displayName.trim(), - api_key: apiKey, - session_key: sessionKey, + api_key: apiKey.trim(), + session_key: sessionKey.trim(), base_url: baseUrl.trim(), }) } @@ -633,6 +670,10 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider }); } + // Only show rate-limit bars when something is actually being consumed. + // 0% bars with "resets in < 1m" are meaningless noise when idle. + const activeBars = usageBars.filter((b) => b.pct > 0); + return (
{usage.error && ( @@ -709,14 +750,14 @@ function UsageStrip({ credentialId, provider }: { credentialId: string; provider
) : (
- {/* API rate-limit diagnostic bars */} - {usageBars.length > 0 ? ( + {/* API rate-limit diagnostic bars โ€” only shown when actively consuming the limit */} + {activeBars.length > 0 ? ( <>
{sourceLabel[usage.source] ?? usage.source} ยท {usage.confidence} confidence
- {usageBars.map((bar) => ( + {activeBars.map((bar) => ( void; onDelete: (cred: ProviderCredentialRead) => void; onToggle: (cred: ProviderCredentialRead) => Promise; showUsage?: boolean; @@ -786,6 +828,7 @@ interface CredentialRowProps { function CredentialRow({ cred, isAdmin, + onEdit, onDelete, onToggle, showUsage = true, @@ -832,6 +875,18 @@ function CredentialRow({
{isAdmin && (
+ {cred.active && ( + + )}