From 96e7614835a6bb39055454523e1c3f49d9e6151e Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 22 Mar 2026 21:17:37 +0800 Subject: [PATCH 01/24] fix: trust proxy headers and align proxy file mysql indexes --- src/server/config.test.ts | 21 +++++++++++++++++++ src/server/config.ts | 1 + .../db/proxyFileSchemaCompatibility.test.ts | 13 ++++++++++++ src/server/db/proxyFileSchemaCompatibility.ts | 4 ++-- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/server/config.test.ts b/src/server/config.test.ts index ecaf6f1..53ea86c 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -85,4 +85,25 @@ describe('buildConfig', () => { expect(response.json()).toEqual({ textLength: largeText.length }); await app.close(); }); + + it('trusts forwarded client IP headers for reverse-proxy deployments', async () => { + const app = Fastify(buildFastifyOptions(buildConfig({}))); + + app.get('/ip', async (request) => ({ + ip: request.ip, + })); + + const response = await app.inject({ + method: 'GET', + url: '/ip', + remoteAddress: '10.0.0.8', + headers: { + 'x-forwarded-for': '203.0.113.5, 10.0.0.8', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ ip: '203.0.113.5' }); + await app.close(); + }); }); diff --git a/src/server/config.ts b/src/server/config.ts index 2b85533..2aeeae8 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -137,6 +137,7 @@ export function buildFastifyOptions( ): FastifyServerOptions { return { logger: true, + trustProxy: true, bodyLimit: appConfig.requestBodyLimit, }; } diff --git a/src/server/db/proxyFileSchemaCompatibility.test.ts b/src/server/db/proxyFileSchemaCompatibility.test.ts index 7bbaf68..ea08615 100644 --- a/src/server/db/proxyFileSchemaCompatibility.test.ts +++ b/src/server/db/proxyFileSchemaCompatibility.test.ts @@ -62,6 +62,19 @@ describe('ensureProxyFileSchemaCompatibility', () => { expect(executedSql.some((sqlText) => ownerIndexPattern.test(sqlText))).toBe(true); }); + it('emits MySQL indexes compatible with generated bootstrap SQL', async () => { + const { inspector, executedSql } = createInspector('mysql'); + + await ensureProxyFileSchemaCompatibility(inspector); + + expect(executedSql).toContain( + 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', + ); + expect(executedSql).toContain( + 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`)', + ); + }); + it('adds missing columns on existing table before ensuring indexes', async () => { const { inspector, executedSql } = createInspector('postgres', { hasTable: true, diff --git a/src/server/db/proxyFileSchemaCompatibility.ts b/src/server/db/proxyFileSchemaCompatibility.ts index 94c0f3c..54cd832 100644 --- a/src/server/db/proxyFileSchemaCompatibility.ts +++ b/src/server/db/proxyFileSchemaCompatibility.ts @@ -127,8 +127,8 @@ const CREATE_INDEX_SQL: Record = { 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', ], mysql: [ - 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`)', - 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`, `owner_id`)', + 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', + 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`)', ], }; From 9290e51ecc53fc71a80bf1f412f4256b3df6c891 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 22 Mar 2026 21:27:09 +0800 Subject: [PATCH 02/24] refactor: consolidate single-source routing and protocol helpers --- ...6-03-22-single-source-consolidation-pr1.md | 254 ++++++++ scripts/dev/copy-runtime-db-generated.test.ts | 21 +- scripts/dev/copy-runtime-db-generated.ts | 22 +- .../cliProfiles/codexProfile.test.ts | 28 + .../proxy-core/cliProfiles/codexProfile.ts | 67 ++ .../providers/claudeProviderProfile.ts | 94 +-- .../providers/codexProviderProfile.ts | 128 +--- .../providers/geminiCliProviderProfile.ts | 77 +-- .../proxy-core/providers/headerUtils.test.ts | 119 ++++ .../proxy-core/providers/headerUtils.ts | 245 +++++++ src/server/proxy-core/surfaces/chatSurface.ts | 601 +++++------------- .../proxy-core/surfaces/geminiSurface.ts | 10 +- .../proxy-core/surfaces/inputFilesSurface.ts | 118 +--- .../surfaces/openAiResponsesSurface.ts | 435 ++++--------- .../proxy-core/surfaces/sharedSurface.test.ts | 471 ++++++++++++++ .../proxy-core/surfaces/sharedSurface.ts | 394 ++++++++++++ src/server/routes/api/accountTokens.ts | 84 +-- src/server/routes/api/accounts.ts | 110 ++-- src/server/routes/api/oauth.test.ts | 18 +- src/server/routes/api/tokens.ts | 16 +- .../routes/proxy/downstreamClientContext.ts | 89 +-- src/server/routes/proxy/responsesWebsocket.ts | 4 +- src/server/routes/proxy/upstreamEndpoint.ts | 221 +------ .../services/accountMutationWorkflow.test.ts | 132 ++++ .../services/accountMutationWorkflow.ts | 172 +++++ src/server/services/backupService.ts | 4 +- .../services/modelService.discovery.test.ts | 6 +- src/server/services/modelService.ts | 16 +- .../services/oauth/oauthAccount.test.ts | 136 ++++ src/server/services/oauth/oauthAccount.ts | 96 ++- src/server/services/oauth/quota.ts | 14 +- src/server/services/oauth/service.ts | 34 +- src/server/services/platforms/claude.ts | 51 +- src/server/services/platforms/cliproxyapi.ts | 51 +- src/server/services/platforms/gemini.ts | 56 +- .../services/platforms/llmUpstream.test.ts | 21 + src/server/services/platforms/openai.ts | 45 +- .../platforms/standardApiProvider.test.ts | 46 ++ .../services/platforms/standardApiProvider.ts | 73 +++ .../services/proxyInputFileResolver.test.ts | 47 ++ src/server/services/proxyInputFileResolver.ts | 35 +- src/server/services/siteProxy.ts | 2 +- src/server/services/tokenRouter.ts | 196 +----- .../anthropic/messages/inbound.ts | 61 -- .../openai/responses/compatibility.ts | 463 +------------- .../openai/responses/conversion.test.ts | 31 +- .../openai/responses/conversion.ts | 23 +- .../openai/responses/normalization.ts | 193 ++++++ src/shared/tokenRouteContract.d.ts | 25 + src/shared/tokenRouteContract.js | 3 + src/shared/tokenRouteContract.test.ts | 12 + src/shared/tokenRoutePatterns.d.ts | 7 + src/shared/tokenRoutePatterns.js | 190 ++++++ src/shared/tokenRoutePatterns.test.ts | 26 + src/web/api.test.ts | 5 + src/web/api.ts | 73 +-- src/web/pages/Accounts.tsx | 28 +- src/web/pages/Settings.tsx | 47 +- src/web/pages/Tokens.tsx | 29 +- .../pages/helpers/accountConnection.test.ts | 30 + src/web/pages/helpers/accountConnection.ts | 23 + src/web/pages/helpers/routeListVisibility.ts | 4 +- src/web/pages/token-routes/types.ts | 28 +- src/web/pages/token-routes/utils.ts | 92 +-- 64 files changed, 3571 insertions(+), 2681 deletions(-) create mode 100644 docs/plans/2026-03-22-single-source-consolidation-pr1.md create mode 100644 src/server/proxy-core/cliProfiles/codexProfile.test.ts create mode 100644 src/server/proxy-core/providers/headerUtils.test.ts create mode 100644 src/server/proxy-core/providers/headerUtils.ts create mode 100644 src/server/proxy-core/surfaces/sharedSurface.test.ts create mode 100644 src/server/proxy-core/surfaces/sharedSurface.ts create mode 100644 src/server/services/accountMutationWorkflow.test.ts create mode 100644 src/server/services/accountMutationWorkflow.ts create mode 100644 src/server/services/oauth/oauthAccount.test.ts create mode 100644 src/server/services/platforms/standardApiProvider.test.ts create mode 100644 src/server/services/platforms/standardApiProvider.ts create mode 100644 src/server/transformers/openai/responses/normalization.ts create mode 100644 src/shared/tokenRouteContract.d.ts create mode 100644 src/shared/tokenRouteContract.js create mode 100644 src/shared/tokenRouteContract.test.ts create mode 100644 src/shared/tokenRoutePatterns.d.ts create mode 100644 src/shared/tokenRoutePatterns.js create mode 100644 src/shared/tokenRoutePatterns.test.ts create mode 100644 src/web/pages/helpers/accountConnection.test.ts create mode 100644 src/web/pages/helpers/accountConnection.ts diff --git a/docs/plans/2026-03-22-single-source-consolidation-pr1.md b/docs/plans/2026-03-22-single-source-consolidation-pr1.md new file mode 100644 index 0000000..96bf4a8 --- /dev/null +++ b/docs/plans/2026-03-22-single-source-consolidation-pr1.md @@ -0,0 +1,254 @@ +# Single Source Consolidation PR1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Land PR1 for the highest-value duplication and double-source issues around responses normalization, shared proxy surface orchestration, provider runtime headers, token-route contract sharing, account mutation workflow, and standard API platform helpers. + +**Architecture:** Keep PR1 focused on extracting shared single-source modules and rewiring existing callers to consume them. Do not expand this PR into schema metamodel rewrites, `chatFormatsCore` breakup, or mobile page scaffold work; those remain follow-up PR scope once the highest-risk protocol and workflow duplications are removed. + +**Tech Stack:** TypeScript, Fastify, Vitest, shared server/web modules under `src/shared`, server proxy-core surfaces/providers/services, React admin pages. + +--- + +### Task 1: Savepoint Commit + +**Files:** +- Commit: `src/server/config.ts` +- Commit: `src/server/config.test.ts` +- Commit: `src/server/db/proxyFileSchemaCompatibility.ts` +- Commit: `src/server/db/proxyFileSchemaCompatibility.test.ts` + +**Step 1: Verify the savepoint changes** + +Run: `npm test -- src/server/config.test.ts src/server/db/proxyFileSchemaCompatibility.test.ts` +Expected: PASS + +**Step 2: Commit the savepoint** + +Run: + +```bash +git add src/server/config.ts src/server/config.test.ts src/server/db/proxyFileSchemaCompatibility.ts src/server/db/proxyFileSchemaCompatibility.test.ts +git commit -m "fix: trust proxy headers and align proxy file mysql indexes" +``` + +**Step 3: Cherry-pick into PR1 branch** + +Run: + +```bash +git cherry-pick +``` + +Expected: first commit preserved inside PR1 branch before the larger consolidation commit. + +### Task 2: Responses Single Source + +**Files:** +- Create: `src/server/transformers/openai/responses/normalization.ts` +- Modify: `src/server/transformers/openai/responses/conversion.ts` +- Modify: `src/server/transformers/openai/responses/compatibility.ts` +- Test: `src/server/transformers/openai/responses/conversion.test.ts` + +**Step 1: Move shared normalization helpers into one source** + +Keep `normalizeResponsesInputForCompatibility`, `normalizeResponsesMessageContent`, and related block normalization in `normalization.ts`. + +**Step 2: Rewire both callers** + +Make `conversion.ts` and `compatibility.ts` import the shared helpers instead of maintaining their own independent copies. + +**Step 3: Verify parity** + +Run: `npm test -- src/server/transformers/openai/responses/conversion.test.ts` +Expected: PASS, including assertions that compatibility exports point at the shared implementation. + +### Task 3: Shared Surface Orchestration + +**Files:** +- Create: `src/server/proxy-core/surfaces/sharedSurface.ts` +- Modify: `src/server/proxy-core/surfaces/chatSurface.ts` +- Modify: `src/server/proxy-core/surfaces/openAiResponsesSurface.ts` +- Modify: `src/server/proxy-core/surfaces/geminiSurface.ts` +- Test: `src/server/proxy-core/surfaces/sharedSurface.test.ts` + +**Step 1: Extract common orchestration primitives** + +Centralize channel selection, dispatch, proxy logging, retry/failure handling, and common failure response shaping in `sharedSurface.ts`. + +**Step 2: Rewire surface callers** + +Make chat and responses surfaces consume the shared orchestration helpers instead of each keeping a parallel flow. + +**Step 3: Verify behavior** + +Run: `npm test -- src/server/proxy-core/surfaces/sharedSurface.test.ts` +Expected: PASS + +### Task 4: Provider Header Builders + +**Files:** +- Create: `src/server/proxy-core/providers/headerUtils.ts` +- Modify: `src/server/proxy-core/providers/codexProviderProfile.ts` +- Modify: `src/server/proxy-core/providers/claudeProviderProfile.ts` +- Modify: `src/server/proxy-core/providers/geminiCliProviderProfile.ts` +- Modify: `src/server/routes/proxy/upstreamEndpoint.ts` +- Test: `src/server/proxy-core/providers/headerUtils.test.ts` + +**Step 1: Extract header parsing and runtime header construction** + +Keep Codex, Claude, and Gemini CLI runtime header shaping in `headerUtils.ts`. + +**Step 2: Remove duplicate builders** + +Make both provider profiles and `upstreamEndpoint.ts` call the shared header builders. + +**Step 3: Verify behavior** + +Run: `npm test -- src/server/proxy-core/providers/headerUtils.test.ts` +Expected: PASS + +### Task 5: Cross-Layer Token Route Contract + +**Files:** +- Create: `src/shared/tokenRouteContract.js` +- Create: `src/shared/tokenRouteContract.d.ts` +- Create: `src/shared/tokenRoutePatterns.js` +- Create: `src/shared/tokenRoutePatterns.d.ts` +- Modify: `src/server/services/tokenRouter.ts` +- Modify: `src/server/routes/api/tokens.ts` +- Modify: `src/web/pages/token-routes/utils.ts` +- Modify: `src/web/pages/token-routes/types.ts` +- Modify: `src/web/pages/helpers/routeListVisibility.ts` +- Modify: `src/web/pages/Settings.tsx` +- Test: `src/shared/tokenRouteContract.test.ts` +- Test: `src/shared/tokenRoutePatterns.test.ts` + +**Step 1: Extract shared route mode and pattern matching** + +Make route mode normalization and model pattern matching authoritative in `src/shared`. + +**Step 2: Rewire server and web** + +Make token router, route APIs, token-routes helpers, and settings consume the shared contract instead of maintaining separate copies. + +**Step 3: Verify behavior** + +Run: `npm test -- src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.test.ts` +Expected: PASS + +### Task 6: Account Mutation Workflow + +**Files:** +- Create: `src/server/services/accountMutationWorkflow.ts` +- Modify: `src/server/routes/api/accounts.ts` +- Modify: `src/server/routes/api/accountTokens.ts` +- Test: `src/server/services/accountMutationWorkflow.test.ts` + +**Step 1: Extract convergence workflow** + +Move token sync, default token ensure, balance refresh, model refresh, and route rebuild sequencing into `accountMutationWorkflow.ts`. + +**Step 2: Rewire controllers** + +Make account and account-token APIs call the workflow service instead of each owning private orchestration logic. + +**Step 3: Verify behavior** + +Run: `npm test -- src/server/services/accountMutationWorkflow.test.ts` +Expected: PASS + +### Task 7: Standard API Provider Base + +**Files:** +- Create: `src/server/services/platforms/standardApiProvider.ts` +- Modify: `src/server/services/platforms/openai.ts` +- Modify: `src/server/services/platforms/claude.ts` +- Modify: `src/server/services/platforms/gemini.ts` +- Modify: `src/server/services/platforms/cliproxyapi.ts` +- Test: `src/server/services/platforms/standardApiProvider.test.ts` +- Test: `src/server/services/platforms/llmUpstream.test.ts` + +**Step 1: Extract shared standard API adapter logic** + +Centralize base URL normalization, `/v1/models` resolution, unsupported login/checkin/balance defaults, and common model fetch behavior. + +**Step 2: Rewire adapters** + +Make OpenAI, Claude, Gemini, and CLIProxyAPI adapters extend the shared base instead of repeating the same template logic. + +**Step 3: Verify behavior** + +Run: `npm test -- src/server/services/platforms/standardApiProvider.test.ts src/server/services/platforms/llmUpstream.test.ts` +Expected: PASS + +### Task 8: Shared Input File Resolution And Web Helpers + +**Files:** +- Modify: `src/server/services/proxyInputFileResolver.ts` +- Modify: `src/server/services/proxyInputFileResolver.test.ts` +- Modify: `src/server/proxy-core/surfaces/inputFilesSurface.ts` +- Create: `src/web/pages/helpers/accountConnection.ts` +- Create: `src/web/pages/helpers/accountConnection.test.ts` +- Modify: `src/web/pages/Accounts.tsx` +- Modify: `src/web/pages/Tokens.tsx` +- Modify: `src/web/api.ts` +- Modify: `src/web/api.test.ts` + +**Step 1: Make route-level file inlining reuse the service implementation** + +Replace the duplicate `inputFilesSurface.ts` implementation with a re-export of the shared resolver service. + +**Step 2: Extract shared web account helpers** + +Move `resolveAccountCredentialMode`, `parsePositiveInt`, and `isTruthyFlag` into a single helper consumed by both Accounts and Tokens. + +**Step 3: De-duplicate proxy test aliases** + +Make `testProxy` / `proxyTest` and stream variants reuse the same implementation function. + +**Step 4: Verify behavior** + +Run: `npm test -- src/server/services/proxyInputFileResolver.test.ts src/web/pages/helpers/accountConnection.test.ts src/web/api.test.ts` +Expected: PASS + +### Task 9: PR1 Verification And Commit + +**Files:** +- Verify: `scripts/dev/copy-runtime-db-generated.ts` +- Verify: `scripts/dev/copy-runtime-db-generated.test.ts` +- Verify: all files touched in Tasks 2-8 + +**Step 1: Run focused regression suite** + +Run: + +```bash +npm test -- scripts/dev/copy-runtime-db-generated.test.ts src/server/proxy-core/cliProfiles/codexProfile.test.ts src/server/proxy-core/providers/headerUtils.test.ts src/server/proxy-core/surfaces/sharedSurface.test.ts src/server/routes/api/oauth.test.ts src/server/services/accountMutationWorkflow.test.ts src/server/services/modelService.discovery.test.ts src/server/services/oauth/oauthAccount.test.ts src/server/services/platforms/standardApiProvider.test.ts src/server/services/platforms/llmUpstream.test.ts src/server/services/proxyInputFileResolver.test.ts src/server/transformers/openai/responses/conversion.test.ts src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.test.ts src/web/api.test.ts src/web/pages/helpers/accountConnection.test.ts +``` + +Expected: PASS + +**Step 2: Run server build** + +Run: `npm run build:server` +Expected: PASS + +**Step 3: Commit PR1 consolidation work** + +Run: + +```bash +git add docs/plans/2026-03-22-single-source-consolidation-pr1.md scripts/dev/copy-runtime-db-generated.test.ts scripts/dev/copy-runtime-db-generated.ts src/server/proxy-core/cliProfiles/codexProfile.ts src/server/proxy-core/cliProfiles/codexProfile.test.ts src/server/proxy-core/providers/headerUtils.ts src/server/proxy-core/providers/headerUtils.test.ts src/server/proxy-core/providers/claudeProviderProfile.ts src/server/proxy-core/providers/codexProviderProfile.ts src/server/proxy-core/providers/geminiCliProviderProfile.ts src/server/proxy-core/surfaces/chatSurface.ts src/server/proxy-core/surfaces/geminiSurface.ts src/server/proxy-core/surfaces/inputFilesSurface.ts src/server/proxy-core/surfaces/openAiResponsesSurface.ts src/server/proxy-core/surfaces/sharedSurface.ts src/server/proxy-core/surfaces/sharedSurface.test.ts src/server/routes/api/accountTokens.ts src/server/routes/api/accounts.ts src/server/routes/api/oauth.test.ts src/server/routes/api/tokens.ts src/server/routes/proxy/downstreamClientContext.ts src/server/routes/proxy/responsesWebsocket.ts src/server/routes/proxy/upstreamEndpoint.ts src/server/services/accountMutationWorkflow.ts src/server/services/accountMutationWorkflow.test.ts src/server/services/backupService.ts src/server/services/modelService.discovery.test.ts src/server/services/modelService.ts src/server/services/oauth/oauthAccount.ts src/server/services/oauth/oauthAccount.test.ts src/server/services/oauth/quota.ts src/server/services/oauth/service.ts src/server/services/platforms/claude.ts src/server/services/platforms/cliproxyapi.ts src/server/services/platforms/gemini.ts src/server/services/platforms/llmUpstream.test.ts src/server/services/platforms/openai.ts src/server/services/platforms/standardApiProvider.ts src/server/services/platforms/standardApiProvider.test.ts src/server/services/proxyInputFileResolver.ts src/server/services/proxyInputFileResolver.test.ts src/server/services/siteProxy.ts src/server/services/tokenRouter.ts src/server/transformers/anthropic/messages/inbound.ts src/server/transformers/openai/responses/compatibility.ts src/server/transformers/openai/responses/conversion.test.ts src/server/transformers/openai/responses/conversion.ts src/server/transformers/openai/responses/normalization.ts src/shared/tokenRouteContract.d.ts src/shared/tokenRouteContract.js src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.d.ts src/shared/tokenRoutePatterns.js src/shared/tokenRoutePatterns.test.ts src/web/api.ts src/web/api.test.ts src/web/pages/Accounts.tsx src/web/pages/Settings.tsx src/web/pages/Tokens.tsx src/web/pages/helpers/accountConnection.ts src/web/pages/helpers/accountConnection.test.ts src/web/pages/helpers/routeListVisibility.ts src/web/pages/token-routes/types.ts src/web/pages/token-routes/utils.ts +git commit -m "refactor: consolidate single-source routing and protocol helpers" +``` + +### Deferred After PR1 + +**Keep out of this PR:** +- `shared/chatFormatsCore.ts` breakup +- Gemini `generate-content` module boundary cleanup +- schema contract/introspection meta-rule unification +- `legacySchemaCompat.ts` feature compatibility dedupe +- mobile page scaffold extraction +- large-file decomposition for `Settings.tsx`, `ModelTester.tsx`, `tokenRouter.ts`, `modelService.ts` diff --git a/scripts/dev/copy-runtime-db-generated.test.ts b/scripts/dev/copy-runtime-db-generated.test.ts index cf75051..61a7d98 100644 --- a/scripts/dev/copy-runtime-db-generated.test.ts +++ b/scripts/dev/copy-runtime-db-generated.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it } from 'vitest'; @@ -16,15 +16,23 @@ afterEach(() => { }); describe('copyRuntimeDbGeneratedAssets', () => { - it('copies generated runtime db artifacts into dist', () => { + it('copies generated runtime db artifacts and shared runtime modules into dist', () => { const repoRoot = mkdtempSync(join(tmpdir(), 'metapi-runtime-db-assets-')); tempDirs.push(repoRoot); const sourceDir = join(repoRoot, 'src', 'server', 'db', 'generated'); const nestedDir = join(sourceDir, 'fixtures'); + const sharedDir = join(repoRoot, 'src', 'shared'); + const staleSharedDistDir = join(repoRoot, 'dist', 'shared'); mkdirSync(nestedDir, { recursive: true }); + mkdirSync(sharedDir, { recursive: true }); + mkdirSync(staleSharedDistDir, { recursive: true }); writeFileSync(join(sourceDir, 'mysql.bootstrap.sql'), 'create table demo ();'); writeFileSync(join(nestedDir, 'baseline.json'), '{"ok":true}'); + writeFileSync(join(sharedDir, 'tokenRouteContract.d.ts'), 'export declare const demo: number;\n'); + writeFileSync(join(sharedDir, 'tokenRouteContract.js'), 'export const demo = 1;\n'); + writeFileSync(join(sharedDir, 'tokenRouteContract.test.ts'), 'throw new Error("should not ship");\n'); + writeFileSync(join(staleSharedDistDir, 'tokenRouteContract.test.ts'), 'stale test artifact\n'); copyRuntimeDbGeneratedAssets(repoRoot); @@ -34,5 +42,14 @@ describe('copyRuntimeDbGeneratedAssets', () => { expect( readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'fixtures', 'baseline.json'), 'utf8'), ).toBe('{"ok":true}'); + expect( + readFileSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.js'), 'utf8'), + ).toBe('export const demo = 1;\n'); + expect( + readFileSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.d.ts'), 'utf8'), + ).toBe('export declare const demo: number;\n'); + expect( + existsSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.test.ts')), + ).toBe(false); }); }); diff --git a/scripts/dev/copy-runtime-db-generated.ts b/scripts/dev/copy-runtime-db-generated.ts index 61b3c0f..ca75128 100644 --- a/scripts/dev/copy-runtime-db-generated.ts +++ b/scripts/dev/copy-runtime-db-generated.ts @@ -1,14 +1,22 @@ -import { cpSync, existsSync, mkdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; function resolveRepoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), '../..'); } +function shouldCopySharedArtifact(sourcePath: string): boolean { + if (statSync(sourcePath).isDirectory()) return true; + const filename = basename(sourcePath); + return filename.endsWith('.js') || filename.endsWith('.d.ts'); +} + export function copyRuntimeDbGeneratedAssets(repoRoot: string = resolveRepoRoot()): void { const sourceDir = resolve(repoRoot, 'src/server/db/generated'); const targetDir = resolve(repoRoot, 'dist/server/db/generated'); + const sharedSourceDir = resolve(repoRoot, 'src/shared'); + const sharedTargetDir = resolve(repoRoot, 'dist/shared'); if (!existsSync(sourceDir)) { throw new Error(`Runtime DB generated assets directory does not exist: ${sourceDir}`); @@ -16,6 +24,16 @@ export function copyRuntimeDbGeneratedAssets(repoRoot: string = resolveRepoRoot( mkdirSync(dirname(targetDir), { recursive: true }); cpSync(sourceDir, targetDir, { recursive: true, force: true }); + + if (existsSync(sharedSourceDir)) { + rmSync(sharedTargetDir, { recursive: true, force: true }); + mkdirSync(dirname(sharedTargetDir), { recursive: true }); + cpSync(sharedSourceDir, sharedTargetDir, { + recursive: true, + force: true, + filter: (sourcePath) => shouldCopySharedArtifact(sourcePath), + }); + } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/src/server/proxy-core/cliProfiles/codexProfile.test.ts b/src/server/proxy-core/cliProfiles/codexProfile.test.ts new file mode 100644 index 0000000..a621b3c --- /dev/null +++ b/src/server/proxy-core/cliProfiles/codexProfile.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { detectCodexOfficialClientApp } from './codexProfile.js'; + +describe('detectCodexOfficialClientApp', () => { + it('detects official Codex clients from originator prefixes', () => { + expect(detectCodexOfficialClientApp({ + originator: 'codex_exec', + })).toEqual({ + clientAppId: 'codex_exec', + clientAppName: 'Codex Exec', + }); + }); + + it('detects official Codex clients from user-agent prefixes', () => { + expect(detectCodexOfficialClientApp({ + 'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3', + })).toEqual({ + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + }); + }); + + it('returns null for non-official Codex clients', () => { + expect(detectCodexOfficialClientApp({ + 'user-agent': 'OpenClaw/1.0', + })).toBe(null); + }); +}); diff --git a/src/server/proxy-core/cliProfiles/codexProfile.ts b/src/server/proxy-core/cliProfiles/codexProfile.ts index 8799843..4b3dbf0 100644 --- a/src/server/proxy-core/cliProfiles/codexProfile.ts +++ b/src/server/proxy-core/cliProfiles/codexProfile.ts @@ -1,5 +1,10 @@ import type { CliProfileDefinition, DetectCliProfileInput } from './types.js'; +type CodexOfficialClientApp = { + clientAppId: string; + clientAppName: string; +}; + const CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES = [ 'codex_cli_rs/', 'codex_vscode/', @@ -16,6 +21,51 @@ const CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES = [ 'codex ', ]; +const CODEX_OFFICIAL_CLIENT_APP_RULES = [ + { + id: 'codex_cli_rs', + name: 'Codex CLI', + userAgentPrefixes: ['codex_cli_rs/'], + originatorPrefixes: ['codex_cli_rs'], + }, + { + id: 'codex_vscode', + name: 'Codex VSCode', + userAgentPrefixes: ['codex_vscode/'], + originatorPrefixes: ['codex_vscode'], + }, + { + id: 'codex_app', + name: 'Codex App', + userAgentPrefixes: ['codex_app/'], + originatorPrefixes: ['codex_app'], + }, + { + id: 'codex_chatgpt_desktop', + name: 'Codex Desktop', + userAgentPrefixes: ['codex_chatgpt_desktop/', 'codex desktop/'], + originatorPrefixes: ['codex_chatgpt_desktop', 'codex desktop'], + }, + { + id: 'codex_atlas', + name: 'Codex Atlas', + userAgentPrefixes: ['codex_atlas/'], + originatorPrefixes: ['codex_atlas'], + }, + { + id: 'codex_exec', + name: 'Codex Exec', + userAgentPrefixes: ['codex_exec/'], + originatorPrefixes: ['codex_exec'], + }, + { + id: 'codex_sdk_ts', + name: 'Codex SDK TS', + userAgentPrefixes: ['codex_sdk_ts/'], + originatorPrefixes: ['codex_sdk_ts'], + }, +] as const; + function headerValueToString(value: unknown): string | null { if (typeof value === 'string') { const trimmed = value.trim(); @@ -73,6 +123,23 @@ function isCodexPath(path: string): boolean { || normalizedPath.startsWith('/v1/messages'); } +export function detectCodexOfficialClientApp( + headers?: Record, +): CodexOfficialClientApp | null { + for (const rule of CODEX_OFFICIAL_CLIENT_APP_RULES) { + const matchesOriginator = Array.isArray(rule.originatorPrefixes) + && matchesHeaderPrefixes(getHeaderValue(headers, 'originator'), [...rule.originatorPrefixes]); + const matchesUserAgent = Array.isArray(rule.userAgentPrefixes) + && matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), [...rule.userAgentPrefixes]); + if (!matchesOriginator && !matchesUserAgent) continue; + return { + clientAppId: rule.id, + clientAppName: rule.name, + }; + } + return null; +} + export function isCodexResponsesSurface(headers?: Record): boolean { return isCodexRequest({ downstreamPath: '/v1/responses', diff --git a/src/server/proxy-core/providers/claudeProviderProfile.ts b/src/server/proxy-core/providers/claudeProviderProfile.ts index fdb428b..946dcfd 100644 --- a/src/server/proxy-core/providers/claudeProviderProfile.ts +++ b/src/server/proxy-core/providers/claudeProviderProfile.ts @@ -1,97 +1,5 @@ import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js'; - -const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)'; -const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; - -function headerValueToString(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed || null; - } - if (Array.isArray(value)) { - for (const item of value) { - if (typeof item !== 'string') continue; - const trimmed = item.trim(); - if (trimmed) return trimmed; - } - } - return null; -} - -function getInputHeader( - headers: Record | Record | undefined, - key: string, -): string | null { - if (!headers) return null; - for (const [candidateKey, candidateValue] of Object.entries(headers)) { - if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; - return headerValueToString(candidateValue); - } - return null; -} - -function mergeClaudeBetaHeader( - explicitValue: string | null, - extraBetas: string[] = [], -): string { - const source = explicitValue || CLAUDE_DEFAULT_BETA_HEADER; - const seen = new Set(); - const merged: string[] = []; - for (const entry of source.split(',')) { - const normalized = entry.trim(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - merged.push(normalized); - } - if (!explicitValue) { - for (const entry of extraBetas) { - const normalized = entry.trim(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - merged.push(normalized); - } - } - return merged.join(','); -} - -function buildClaudeRuntimeHeaders(input: { - baseHeaders: Record; - claudeHeaders: Record; - anthropicVersion: string; - stream: boolean; - isClaudeOauthUpstream: boolean; - tokenValue: string; -}): Record { - const anthropicBeta = mergeClaudeBetaHeader( - getInputHeader(input.claudeHeaders, 'anthropic-beta'), - ); - const headers: Record = { - ...input.baseHeaders, - ...input.claudeHeaders, - 'anthropic-version': input.anthropicVersion, - ...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}), - 'Anthropic-Dangerous-Direct-Browser-Access': 'true', - 'X-App': 'cli', - 'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0', - 'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0', - 'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0', - 'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node', - 'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js', - 'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64', - 'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows', - 'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600', - 'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT, - Connection: 'keep-alive', - Accept: input.stream ? 'text/event-stream' : 'application/json', - 'Accept-Encoding': 'gzip, deflate, br, zstd', - }; - if (input.isClaudeOauthUpstream) { - headers.Authorization = `Bearer ${input.tokenValue}`; - } else { - headers['x-api-key'] = input.tokenValue; - } - return headers; -} +import { buildClaudeRuntimeHeaders } from './headerUtils.js'; export const claudeProviderProfile: ProviderProfile = { id: 'claude', diff --git a/src/server/proxy-core/providers/codexProviderProfile.ts b/src/server/proxy-core/providers/codexProviderProfile.ts index 547449b..58bb20b 100644 --- a/src/server/proxy-core/providers/codexProviderProfile.ts +++ b/src/server/proxy-core/providers/codexProviderProfile.ts @@ -1,130 +1,34 @@ -import { createHash, randomUUID } from 'node:crypto'; import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js'; import { config } from '../../config.js'; - -const CODEX_CLIENT_VERSION = '0.101.0'; -const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; +import { buildCodexRuntimeHeaders, getInputHeader } from './headerUtils.js'; function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function headerValueToString(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed || null; - } - if (Array.isArray(value)) { - for (const item of value) { - if (typeof item !== 'string') continue; - const trimmed = item.trim(); - if (trimmed) return trimmed; - } - } - return null; -} - -function getInputHeader( - headers: Record | Record | undefined, - key: string, -): string | null { - if (!headers) return null; - for (const [candidateKey, candidateValue] of Object.entries(headers)) { - if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; - return headerValueToString(candidateValue); - } - return null; -} - -function uuidFromSeed(seed: string): string { - const hash = createHash('sha1').update(seed).digest(); - const bytes = new Uint8Array(hash.subarray(0, 16)); - bytes[6] = (bytes[6]! & 0x0f) | 0x50; - bytes[8] = (bytes[8]! & 0x3f) | 0x80; - const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); - return [ - hex.slice(0, 8), - hex.slice(8, 12), - hex.slice(12, 16), - hex.slice(16, 20), - hex.slice(20, 32), - ].join('-'); -} - -function buildCodexRuntimeHeaders(input: { - baseHeaders: Record; - providerHeaders?: Record; - oauthProvider?: string; - explicitSessionId?: string | null; - continuityKey?: string | null; -}): Record { - const authorization = ( - getInputHeader(input.baseHeaders, 'authorization') - || getInputHeader(input.baseHeaders, 'Authorization') - || '' - ); - const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs'; - const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id'); - const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION; - const isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex'; - const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : ''; - const websocketTransport = getInputHeader(input.baseHeaders, 'x-metapi-responses-websocket-transport') === '1'; - const configuredBetaFeatures = ( - isCodexOauth && websocketTransport - ? asTrimmedString(config.codexHeaderDefaults.betaFeatures) - : '' - ); - const userAgent = configuredUserAgent || getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT; - const codexBetaFeatures = getInputHeader(input.baseHeaders, 'x-codex-beta-features') || configuredBetaFeatures; - const codexTurnState = getInputHeader(input.baseHeaders, 'x-codex-turn-state'); - const codexTurnMetadata = getInputHeader(input.baseHeaders, 'x-codex-turn-metadata'); - const timingMetrics = getInputHeader(input.baseHeaders, 'x-responsesapi-include-timing-metrics'); - const openAiBeta = getInputHeader(input.baseHeaders, 'openai-beta') - || (websocketTransport ? asTrimmedString(config.codexResponsesWebsocketBeta) : null); - const explicitSessionId = asTrimmedString(input.explicitSessionId); - const continuityKey = asTrimmedString(input.continuityKey); - const sessionId = ( - getInputHeader(input.baseHeaders, 'session_id') - || getInputHeader(input.baseHeaders, 'session-id') - || explicitSessionId - || (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null) - || randomUUID() - ); - const conversationId = ( - getInputHeader(input.baseHeaders, 'conversation_id') - || getInputHeader(input.baseHeaders, 'conversation-id') - || explicitSessionId - || (continuityKey ? sessionId : null) - ); - - return { - Authorization: authorization, - 'Content-Type': 'application/json', - ...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}), - Originator: originator, - Version: version, - ...(codexBetaFeatures ? { 'x-codex-beta-features': codexBetaFeatures } : {}), - ...(codexTurnState ? { 'x-codex-turn-state': codexTurnState } : {}), - ...(codexTurnMetadata ? { 'x-codex-turn-metadata': codexTurnMetadata } : {}), - ...(timingMetrics ? { 'x-responsesapi-include-timing-metrics': timingMetrics } : {}), - ...(openAiBeta ? { 'OpenAI-Beta': openAiBeta } : {}), - Session_id: sessionId, - ...(conversationId ? { Conversation_id: conversationId } : {}), - 'User-Agent': userAgent, - Accept: 'text/event-stream', - Connection: 'Keep-Alive', - }; -} - export const codexProviderProfile: ProviderProfile = { id: 'codex', prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest { + const isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex'; + const websocketTransport = getInputHeader(input.baseHeaders, 'x-metapi-responses-websocket-transport') === '1'; + const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : ''; + const configuredBetaFeatures = ( + isCodexOauth && websocketTransport + ? asTrimmedString(config.codexHeaderDefaults.betaFeatures) + : '' + ); const headers = buildCodexRuntimeHeaders({ baseHeaders: input.baseHeaders, providerHeaders: input.providerHeaders, - oauthProvider: input.oauthProvider, explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null, continuityKey: asTrimmedString(input.codexSessionCacheKey) || null, + userAgentOverride: configuredUserAgent || null, + codexBetaFeatures: getInputHeader(input.baseHeaders, 'x-codex-beta-features') || configuredBetaFeatures, + codexTurnState: getInputHeader(input.baseHeaders, 'x-codex-turn-state'), + codexTurnMetadata: getInputHeader(input.baseHeaders, 'x-codex-turn-metadata'), + timingMetrics: getInputHeader(input.baseHeaders, 'x-responsesapi-include-timing-metrics'), + openAiBeta: getInputHeader(input.baseHeaders, 'openai-beta') + || (websocketTransport ? asTrimmedString(config.codexResponsesWebsocketBeta) : null), }); const codexSessionId = getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'); const shouldInjectDerivedPromptCacheKey = !!codexSessionId diff --git a/src/server/proxy-core/providers/geminiCliProviderProfile.ts b/src/server/proxy-core/providers/geminiCliProviderProfile.ts index 6b9182f..7184146 100644 --- a/src/server/proxy-core/providers/geminiCliProviderProfile.ts +++ b/src/server/proxy-core/providers/geminiCliProviderProfile.ts @@ -1,60 +1,10 @@ import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js'; +import { buildGeminiCliRuntimeHeaders } from './headerUtils.js'; function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function headerValueToString(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed || null; - } - if (Array.isArray(value)) { - for (const item of value) { - if (typeof item !== 'string') continue; - const trimmed = item.trim(); - if (trimmed) return trimmed; - } - } - return null; -} - -function getInputHeader( - headers: Record | Record | undefined, - key: string, -): string | null { - if (!headers) return null; - for (const [candidateKey, candidateValue] of Object.entries(headers)) { - if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; - return headerValueToString(candidateValue); - } - return null; -} - -function parseGeminiCliUserAgentRuntime(userAgent: string | null): { - version: string; - platform: string; - arch: string; -} | null { - if (!userAgent) return null; - const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim()); - if (!match) return null; - return { - version: match[1] || '0.31.0', - platform: match[2] || 'win32', - arch: match[3] || 'x64', - }; -} - -function buildGeminiCLIUserAgent(modelName: string, existingUserAgent?: string | null): string { - const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null); - const version = parsed?.version || '0.31.0'; - const platform = parsed?.platform || 'win32'; - const arch = parsed?.arch || 'x64'; - const effectiveModel = asTrimmedString(modelName) || 'unknown'; - return `GeminiCLI/${version}/${effectiveModel} (${platform}; ${arch})`; -} - function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction { if (action) return action; return stream ? 'streamGenerateContent' : 'generateContent'; @@ -74,25 +24,12 @@ export const geminiCliProviderProfile: ProviderProfile = { throw new Error('gemini-cli oauth project id missing'); } const action = resolveAction(input.action, input.stream); - const apiClient = ( - getInputHeader(input.providerHeaders, 'x-goog-api-client') - || getInputHeader(input.baseHeaders, 'x-goog-api-client') - ); - const userAgent = buildGeminiCLIUserAgent( - input.modelName, - getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'), - ); - const headers: Record = { - Authorization: input.baseHeaders.Authorization, - 'Content-Type': 'application/json', - 'User-Agent': userAgent, - }; - if (apiClient) { - headers['X-Goog-Api-Client'] = apiClient; - } - if (action === 'streamGenerateContent') { - headers.Accept = 'text/event-stream'; - } + const headers = buildGeminiCliRuntimeHeaders({ + baseHeaders: input.baseHeaders, + providerHeaders: input.providerHeaders, + modelName: input.modelName, + stream: action === 'streamGenerateContent', + }); return { path: resolvePath(action), headers, diff --git a/src/server/proxy-core/providers/headerUtils.test.ts b/src/server/proxy-core/providers/headerUtils.test.ts new file mode 100644 index 0000000..bc79c84 --- /dev/null +++ b/src/server/proxy-core/providers/headerUtils.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; + +describe('provider header utils', () => { + it('coerces header values and performs case-insensitive lookups', async () => { + const { headerValueToString, getInputHeader } = await import('./headerUtils.js'); + + expect(headerValueToString(' value ')).toBe('value'); + expect(headerValueToString(['', ' first ', 'second'])).toBe('first'); + expect(getInputHeader({ Authorization: 'Bearer test' }, 'authorization')).toBe('Bearer test'); + }); + + it('derives stable uuids from seeds', async () => { + const { uuidFromSeed } = await import('./headerUtils.js'); + + expect(uuidFromSeed('seed-1')).toBe(uuidFromSeed('seed-1')); + expect(uuidFromSeed('seed-1')).not.toBe(uuidFromSeed('seed-2')); + expect(uuidFromSeed('seed-1')).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('merges claude beta headers without duplicating entries', async () => { + const { mergeClaudeBetaHeader } = await import('./headerUtils.js'); + + expect( + mergeClaudeBetaHeader(null, 'beta-a,beta-b', ['beta-b', 'beta-c']), + ).toBe('beta-a,beta-b,beta-c'); + expect( + mergeClaudeBetaHeader('custom-a,custom-b', 'beta-a,beta-b', ['beta-c']), + ).toBe('custom-a,custom-b'); + }); + + it('preserves gemini cli runtime metadata when rebuilding user agents', async () => { + const { + buildGeminiCliUserAgent, + parseGeminiCliUserAgentRuntime, + } = await import('./headerUtils.js'); + + expect( + parseGeminiCliUserAgentRuntime('GeminiCLI/0.55.0/gemini-2.5-pro (darwin; arm64)'), + ).toEqual({ + version: '0.55.0', + platform: 'darwin', + arch: 'arm64', + }); + expect( + buildGeminiCliUserAgent( + 'gemini-2.5-flash', + 'GeminiCLI/0.55.0/gemini-2.5-pro (darwin; arm64)', + ), + ).toBe('GeminiCLI/0.55.0/gemini-2.5-flash (darwin; arm64)'); + }); + + it('builds codex runtime headers with continuity-derived session identifiers', async () => { + const { buildCodexRuntimeHeaders } = await import('./headerUtils.js'); + + const headers = buildCodexRuntimeHeaders({ + baseHeaders: { + authorization: 'Bearer test', + version: '0.101.0', + }, + providerHeaders: { + originator: 'codex_cli_rs', + 'chatgpt-account-id': 'acct-1', + }, + continuityKey: 'cache-key-1', + explicitSessionId: null, + }); + + expect(headers.Authorization).toBe('Bearer test'); + expect(headers.Originator).toBe('codex_cli_rs'); + expect(headers['Chatgpt-Account-Id']).toBe('acct-1'); + expect(headers.Session_id).toMatch(/^[0-9a-f-]{36}$/); + expect(headers.Conversation_id).toBe(headers.Session_id); + expect(headers.Accept).toBe('text/event-stream'); + }); + + it('builds claude runtime headers with merged betas and oauth bearer auth', async () => { + const { buildClaudeRuntimeHeaders } = await import('./headerUtils.js'); + + const headers = buildClaudeRuntimeHeaders({ + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders: { + 'anthropic-beta': 'custom-beta', + }, + anthropicVersion: '2023-06-01', + stream: true, + isClaudeOauthUpstream: true, + tokenValue: 'oauth-token', + }); + + expect(headers['anthropic-version']).toBe('2023-06-01'); + expect(headers['anthropic-beta']).toBe('custom-beta'); + expect(headers.Authorization).toBe('Bearer oauth-token'); + expect(headers['x-api-key']).toBeUndefined(); + expect(headers.Accept).toBe('text/event-stream'); + }); + + it('builds gemini cli runtime headers with preserved api client metadata', async () => { + const { buildGeminiCliRuntimeHeaders } = await import('./headerUtils.js'); + + const headers = buildGeminiCliRuntimeHeaders({ + baseHeaders: { + Authorization: 'Bearer test', + }, + providerHeaders: { + 'x-goog-api-client': 'gl-node/22 gccl/0.31.0', + 'user-agent': 'GeminiCLI/0.55.0/gemini-2.5-pro (darwin; arm64)', + }, + modelName: 'gemini-2.5-flash', + stream: true, + }); + + expect(headers.Authorization).toBe('Bearer test'); + expect(headers['X-Goog-Api-Client']).toBe('gl-node/22 gccl/0.31.0'); + expect(headers['User-Agent']).toBe('GeminiCLI/0.55.0/gemini-2.5-flash (darwin; arm64)'); + expect(headers.Accept).toBe('text/event-stream'); + }); +}); diff --git a/src/server/proxy-core/providers/headerUtils.ts b/src/server/proxy-core/providers/headerUtils.ts new file mode 100644 index 0000000..3b1ac8a --- /dev/null +++ b/src/server/proxy-core/providers/headerUtils.ts @@ -0,0 +1,245 @@ +import { createHash, randomUUID } from 'node:crypto'; + +const CODEX_CLIENT_VERSION = '0.101.0'; +const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; +const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)'; +const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; + +export function headerValueToString(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || null; + } + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + return null; +} + +export function getInputHeader( + headers: Record | Record | undefined, + key: string, +): string | null { + if (!headers) return null; + for (const [candidateKey, candidateValue] of Object.entries(headers)) { + if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; + return headerValueToString(candidateValue); + } + return null; +} + +export function uuidFromSeed(seed: string): string { + const hash = createHash('sha1').update(seed).digest(); + const bytes = new Uint8Array(hash.subarray(0, 16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join('-'); +} + +export function mergeClaudeBetaHeader( + explicitValue: string | null, + defaultValue: string, + extraBetas: string[] = [], +): string { + const source = explicitValue || defaultValue; + const seen = new Set(); + const merged: string[] = []; + for (const entry of source.split(',')) { + const normalized = entry.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + merged.push(normalized); + } + if (!explicitValue) { + for (const entry of extraBetas) { + const normalized = entry.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + merged.push(normalized); + } + } + return merged.join(','); +} + +export function parseGeminiCliUserAgentRuntime(userAgent: string | null): { + version: string; + platform: string; + arch: string; +} | null { + if (!userAgent) return null; + const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim()); + if (!match) return null; + return { + version: match[1] || '0.31.0', + platform: match[2] || 'win32', + arch: match[3] || 'x64', + }; +} + +export function buildGeminiCliUserAgent(modelName: string, existingUserAgent?: string | null): string { + const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null); + const version = parsed?.version || '0.31.0'; + const platform = parsed?.platform || 'win32'; + const arch = parsed?.arch || 'x64'; + const effectiveModel = typeof modelName === 'string' ? modelName.trim() : ''; + return `GeminiCLI/${version}/${effectiveModel || 'unknown'} (${platform}; ${arch})`; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function buildCodexRuntimeHeaders(input: { + baseHeaders: Record; + providerHeaders?: Record; + explicitSessionId?: string | null; + continuityKey?: string | null; + versionDefault?: string; + userAgentDefault?: string; + userAgentOverride?: string | null; + originatorDefault?: string; + codexBetaFeatures?: string | null; + codexTurnState?: string | null; + codexTurnMetadata?: string | null; + timingMetrics?: string | null; + openAiBeta?: string | null; +}): Record { + const authorization = ( + getInputHeader(input.baseHeaders, 'authorization') + || getInputHeader(input.baseHeaders, 'Authorization') + || '' + ); + const originator = getInputHeader(input.providerHeaders, 'originator') + || input.originatorDefault + || 'codex_cli_rs'; + const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id'); + const version = getInputHeader(input.baseHeaders, 'version') + || input.versionDefault + || CODEX_CLIENT_VERSION; + const userAgent = input.userAgentOverride + || getInputHeader(input.baseHeaders, 'user-agent') + || input.userAgentDefault + || CODEX_DEFAULT_USER_AGENT; + const codexBetaFeatures = input.codexBetaFeatures || null; + const codexTurnState = input.codexTurnState || null; + const codexTurnMetadata = input.codexTurnMetadata || null; + const timingMetrics = input.timingMetrics || null; + const openAiBeta = input.openAiBeta || null; + const explicitSessionId = asTrimmedString(input.explicitSessionId); + const continuityKey = asTrimmedString(input.continuityKey); + const sessionId = ( + getInputHeader(input.baseHeaders, 'session_id') + || getInputHeader(input.baseHeaders, 'session-id') + || explicitSessionId + || (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null) + || randomUUID() + ); + const conversationId = ( + getInputHeader(input.baseHeaders, 'conversation_id') + || getInputHeader(input.baseHeaders, 'conversation-id') + || explicitSessionId + || (continuityKey ? sessionId : null) + ); + + return { + Authorization: authorization, + 'Content-Type': 'application/json', + ...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}), + Originator: originator, + Version: version, + ...(codexBetaFeatures ? { 'x-codex-beta-features': codexBetaFeatures } : {}), + ...(codexTurnState ? { 'x-codex-turn-state': codexTurnState } : {}), + ...(codexTurnMetadata ? { 'x-codex-turn-metadata': codexTurnMetadata } : {}), + ...(timingMetrics ? { 'x-responsesapi-include-timing-metrics': timingMetrics } : {}), + ...(openAiBeta ? { 'OpenAI-Beta': openAiBeta } : {}), + Session_id: sessionId, + ...(conversationId ? { Conversation_id: conversationId } : {}), + 'User-Agent': userAgent, + Accept: 'text/event-stream', + Connection: 'Keep-Alive', + }; +} + +export function buildClaudeRuntimeHeaders(input: { + baseHeaders: Record; + claudeHeaders: Record; + anthropicVersion: string; + stream: boolean; + isClaudeOauthUpstream: boolean; + tokenValue: string; + extraBetas?: string[]; + defaultBetaHeader?: string; + defaultUserAgent?: string; +}): Record { + const anthropicBeta = mergeClaudeBetaHeader( + getInputHeader(input.claudeHeaders, 'anthropic-beta'), + input.defaultBetaHeader || CLAUDE_DEFAULT_BETA_HEADER, + input.extraBetas, + ); + const headers: Record = { + ...input.baseHeaders, + ...input.claudeHeaders, + 'anthropic-version': input.anthropicVersion, + ...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}), + 'Anthropic-Dangerous-Direct-Browser-Access': 'true', + 'X-App': 'cli', + 'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0', + 'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0', + 'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0', + 'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node', + 'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js', + 'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64', + 'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows', + 'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600', + 'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || input.defaultUserAgent || CLAUDE_DEFAULT_USER_AGENT, + Connection: 'keep-alive', + Accept: input.stream ? 'text/event-stream' : 'application/json', + 'Accept-Encoding': 'gzip, deflate, br, zstd', + }; + if (input.isClaudeOauthUpstream) { + headers.Authorization = `Bearer ${input.tokenValue}`; + } else { + headers['x-api-key'] = input.tokenValue; + } + return headers; +} + +export function buildGeminiCliRuntimeHeaders(input: { + baseHeaders: Record; + providerHeaders?: Record; + modelName: string; + stream: boolean; +}): Record { + const apiClient = ( + getInputHeader(input.providerHeaders, 'x-goog-api-client') + || getInputHeader(input.baseHeaders, 'x-goog-api-client') + ); + const userAgent = buildGeminiCliUserAgent( + input.modelName, + getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'), + ); + + const headers: Record = { + Authorization: input.baseHeaders.Authorization, + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }; + if (apiClient) { + headers['X-Goog-Api-Client'] = apiClient; + } + if (input.stream) { + headers.Accept = 'text/event-stream'; + } + return headers; +} diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index fdea9b3..7b96d8a 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -1,13 +1,9 @@ import { TextDecoder } from 'node:util'; import type { FastifyRequest, FastifyReply } from 'fastify'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { reportProxyAllFailed } from '../../services/alertService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { buildClaudeCountTokensUpstreamRequest, @@ -21,11 +17,9 @@ import { getDownstreamRoutingPolicy, recordDownstreamCostUsage, } from '../../routes/proxy/downstreamPolicy.js'; -import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; @@ -37,8 +31,7 @@ import { import { buildOauthProviderHeaders, } from '../../services/oauth/service.js'; -import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; -import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; import { collectResponsesFinalPayloadFromSse, @@ -50,10 +43,13 @@ import { createGeminiCliStreamReader, unwrapGeminiCliPayload, } from '../../routes/proxy/geminiCliCompat.js'; -import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; -import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; -import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { + createSurfaceFailureToolkit, + createSurfaceDispatchRequest, + selectSurfaceChannelForAttempt, +} from './sharedSurface.js'; const MAX_RETRIES = 2; @@ -114,19 +110,24 @@ export async function handleChatSurfaceRequest( proxyToken: getProxyAuthContext(request)?.token || null, }); const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const failureToolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath, + maxRetries: MAX_RETRIES, + clientContext, + downstreamApiKeyId, + }); const excludeChannelIds: number[] = []; let retryCount = 0; while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } + const selected = await selectSurfaceChannelForAttempt({ + requestedModel, + downstreamPolicy, + excludeChannelIds, + retryCount, + }); if (!selected) { await reportProxyAllFailed({ @@ -141,7 +142,7 @@ export async function handleChatSurfaceRequest( excludeChannelIds.push(selected.channel.id); const modelName = selected.actualModel || requestedModel; - const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const oauth = getOauthInfoFromAccount(selected.account); const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; const endpointCandidates = [ ...await resolveUpstreamEndpointCandidates( @@ -170,7 +171,7 @@ export async function handleChatSurfaceRequest( }; const buildProviderHeaders = () => ( buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + account: selected.account, downstreamHeaders: request.headers as Record, }) ); @@ -204,22 +205,10 @@ export async function handleChatSurfaceRequest( runtime: endpointRequest.runtime, }; }; - const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig); - const dispatchRequest = ( - compatibilityRequest: BuiltEndpointRequest, - targetUrl?: string, - ) => ( - dispatchRuntimeRequest({ - siteUrl: selected.site.url, - targetUrl, - request: compatibilityRequest, - buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }, channelProxyUrl), - }) - ); + const dispatchRequest = createSurfaceDispatchRequest({ + site: selected.site, + accountExtraConfig: selected.account.extraConfig, + }); const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({ downstreamFormat, endpointCandidates, @@ -286,82 +275,34 @@ export async function handleChatSurfaceRequest( }, shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: (ctx) => { - logProxy( - selected, - requestedModel, - 'failed', - ctx.response.status, - Date.now() - startTime, - ctx.errText, - retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - downstreamApiKeyId, - ); - }, - }); + return failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'failed', + httpStatus: ctx.response.status, + latencyMs: Date.now() - startTime, + errorMessage: ctx.errText, + retryCount, + }); + }, + }); if (!endpointResult.ok) { - const status = endpointResult.status || 502; - const errText = endpointResult.errText || 'unknown error'; - const rawErrText = endpointResult.rawErrText || errText; - tokenRouter.recordFailure(selected.channel.id, { - status, - errorText: rawErrText, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleUpstreamFailure({ selected, requestedModel, - 'failed', - status, - Date.now() - startTime, - errText, + modelName, + status: endpointResult.status || 502, + errText: endpointResult.errText || 'unknown error', + rawErrText: endpointResult.rawErrText, + latencyMs: Date.now() - startTime, retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - downstreamApiKeyId, - ); - await recordOauthQuotaResetHint({ - accountId: selected.account.id, - statusCode: status, - errorText: rawErrText, }); - - if (isTokenExpiredError({ status, message: errText })) { - await reportTokenExpired({ - accountId: selected.account.id, - username: selected.account.username, - siteName: selected.site.name, - detail: `HTTP ${status}`, - }); - } - - if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - - await reportProxyAllFailed({ - model: requestedModel, - reason: `upstream returned HTTP ${status}`, - }); - - return reply.code(status).send({ - error: { message: errText, type: 'upstream_error' }, - }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } const upstream = endpointResult.upstream; @@ -418,25 +359,18 @@ export async function handleChatSurfaceRequest( ); const latency = Date.now() - startTime; if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id, modelName); - logProxy( + await failureToolkit.recordStreamFailure({ selected, requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); return; } return; @@ -454,71 +388,41 @@ export async function handleChatSurfaceRequest( const latency = Date.now() - startTime; const failure = detectProxyFailure({ rawText, usage: parsedUsage }); if (failure) { - tokenRouter.recordFailure(selected.channel.id, { - status: failure.status, - errorText: failure.reason, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleDetectedFailure({ selected, requestedModel, - 'failed', - failure.status, - latency, - failure.reason, + modelName, + failure, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); - - if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - - await reportProxyAllFailed({ - model: requestedModel, - reason: failure.reason, - }); - - return reply.code(failure.status).send({ - error: { message: failure.reason, type: 'upstream_error' }, - }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } startSseResponse(); const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id, { - status: 502, - errorText: streamResult.errorMessage, - modelName, - }); - logProxy( + await failureToolkit.recordStreamFailure({ selected, requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); return; } } else { @@ -550,29 +454,19 @@ export async function handleChatSurfaceRequest( const latency = Date.now() - startTime; if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id, { - status: 502, - errorText: streamResult.errorMessage, - modelName, - }); - logProxy( + await failureToolkit.recordStreamFailure({ selected, requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); return; } @@ -609,24 +503,21 @@ export async function handleChatSurfaceRequest( tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); recordDownstreamCostUsage(request, estimatedCost); - logProxy( + await failureToolkit.log({ selected, - requestedModel, - 'success', - 200, - latency, - null, + modelRequested: requestedModel, + status: 'success', + httpStatus: 200, + latencyMs: latency, + errorMessage: null, retryCount, - downstreamPath, - resolvedUsage.promptTokens, - resolvedUsage.completionTokens, - resolvedUsage.totalTokens, + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + upstreamPath: successfulUpstreamPath, + }); return; } @@ -658,43 +549,23 @@ export async function handleChatSurfaceRequest( const parsedUsage = parseProxyUsage(upstreamData); const failure = detectProxyFailure({ rawText, usage: parsedUsage }); if (failure) { - tokenRouter.recordFailure(selected.channel.id, { - status: failure.status, - errorText: failure.reason, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleDetectedFailure({ selected, requestedModel, - 'failed', - failure.status, - latency, - failure.reason, + modelName, + failure, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); - - if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - - await reportProxyAllFailed({ - model: requestedModel, - reason: failure.reason, - }); - - return reply.code(failure.status).send({ - error: { message: failure.reason, type: 'upstream_error' }, - }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText); const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage); @@ -725,66 +596,37 @@ export async function handleChatSurfaceRequest( tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); recordDownstreamCostUsage(request, estimatedCost); - logProxy( + await failureToolkit.log({ selected, - requestedModel, - 'success', - 200, - latency, - null, + modelRequested: requestedModel, + status: 'success', + httpStatus: 200, + latencyMs: latency, + errorMessage: null, retryCount, - downstreamPath, - resolvedUsage.promptTokens, - resolvedUsage.completionTokens, - resolvedUsage.totalTokens, + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + upstreamPath: successfulUpstreamPath, + }); return reply.send(downstreamResponse); } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id, { - errorText: err?.message, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleExecutionError({ selected, requestedModel, - 'failed', - 0, - Date.now() - startTime, - err?.message || 'network error', + modelName, + errorMessage: err?.message || 'network failure', + latencyMs: Date.now() - startTime, retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - downstreamApiKeyId, - ); - - if (retryCount < MAX_RETRIES) { + }); + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - - await reportProxyAllFailed({ - model: requestedModel, - reason: err?.message || 'network failure', - }); - - return reply.code(502).send({ - error: { - message: `Upstream error: ${err?.message || 'network failure'}`, - type: 'upstream_error', - }, - }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } } } @@ -845,18 +687,23 @@ export async function handleClaudeCountTokensSurfaceRequest( }); const downstreamPolicy = getDownstreamRoutingPolicy(request); const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const failureToolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath, + maxRetries: MAX_RETRIES, + clientContext, + downstreamApiKeyId, + }); const excludeChannelIds: number[] = []; let retryCount = 0; while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } + const selected = await selectSurfaceChannelForAttempt({ + requestedModel, + downstreamPolicy, + excludeChannelIds, + retryCount, + }); if (!selected) { await reportProxyAllFailed({ @@ -891,7 +738,7 @@ export async function handleClaudeCountTokensSurfaceRequest( }, }); } - const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const oauth = getOauthInfoFromAccount(selected.account); const startTime = Date.now(); const buildRequest = () => { @@ -914,15 +761,11 @@ export async function handleClaudeCountTokensSurfaceRequest( try { let upstreamRequest = buildRequest(); - let upstream = await dispatchRuntimeRequest({ - siteUrl: selected.site.url, - request: upstreamRequest, - buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)), + const dispatchRequest = createSurfaceDispatchRequest({ + site: selected.site, + accountExtraConfig: selected.account.extraConfig, }); + let upstream = await dispatchRequest(upstreamRequest); if ((upstream.status === 401 || upstream.status === 403) && oauth) { try { @@ -934,15 +777,7 @@ export async function handleClaudeCountTokensSurfaceRequest( extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, }; upstreamRequest = buildRequest(); - upstream = await dispatchRuntimeRequest({ - siteUrl: selected.site.url, - request: upstreamRequest, - buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)), - }); + upstream = await dispatchRequest(upstreamRequest); } catch { // Fall through to the regular upstream error handling below. } @@ -959,148 +794,50 @@ export async function handleClaudeCountTokensSurfaceRequest( } if (!upstream.ok) { - tokenRouter.recordFailure(selected.channel.id, { - status: upstream.status, - errorText: typeof payload === 'string' ? payload : text, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleUpstreamFailure({ selected, requestedModel, - 'failed', - upstream.status, - latency, - typeof payload === 'string' ? payload : JSON.stringify(payload), + modelName, + status: upstream.status, + errText: typeof payload === 'string' ? payload : JSON.stringify(payload), + rawErrText: typeof payload === 'string' ? payload : text, + latencyMs: latency, retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - upstreamRequest.path, - clientContext, - downstreamApiKeyId, - ); - if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) { + }); + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - return reply.code(upstream.status).type(contentType).send(payload); + return reply.code(failureOutcome.status).type(contentType).send(payload); } tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName); recordDownstreamCostUsage(request, 0); - logProxy( + await failureToolkit.log({ selected, - requestedModel, - 'success', - upstream.status, - latency, - null, + modelRequested: requestedModel, + status: 'success', + httpStatus: upstream.status, + latencyMs: latency, + errorMessage: null, retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - upstreamRequest.path, - clientContext, - downstreamApiKeyId, - ); + upstreamPath: upstreamRequest.path, + }); return reply.code(upstream.status).type(contentType).send(payload); } catch (error: any) { - tokenRouter.recordFailure(selected.channel.id, { - errorText: error?.message, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleExecutionError({ selected, requestedModel, - 'failed', - 0, - Date.now() - startTime, - error?.message || 'network error', + modelName, + errorMessage: error?.message || 'network failure', + latencyMs: Date.now() - startTime, retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - downstreamApiKeyId, - ); - if (retryCount < MAX_RETRIES) { + }); + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - return reply.code(502).send({ - error: { - message: `Upstream error: ${error?.message || 'network failure'}`, - type: 'upstream_error', - }, - }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } } } - -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, - downstreamPath: string, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, - upstreamPath: string | null = null, - clientContext: DownstreamClientContext | null = null, - downstreamApiKeyId: number | null = null, -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' - ? clientContext.clientKind - : null, - sessionId: clientContext?.sessionId || null, - traceHint: clientContext?.traceHint || null, - downstreamPath, - upstreamPath, - errorMessage, - }); - await insertProxyLog({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - downstreamApiKeyId, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails, - clientFamily: clientContext?.clientKind || null, - clientAppId: clientContext?.clientAppId || null, - clientAppName: clientContext?.clientAppName || null, - clientConfidence: clientContext?.clientConfidence || null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }); - } catch (error) { - console.warn('[proxy/chat] failed to write proxy log', error); - } -} diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index 8e0ff6d..f531c3f 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -8,7 +8,7 @@ import { parseProxyUsage } from '../../services/proxyUsageParser.js'; import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; -import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; +import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; @@ -407,7 +407,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}), actualModel, ); - let oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + let oauth = getOauthInfoFromAccount(selected.account); const isGeminiCli = isGeminiCliPlatform(selected.site.platform); const isInternalGemini = isInternalGeminiPlatform(selected.site.platform); const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform); @@ -463,7 +463,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { ...(isStreamAction ? { Accept: 'text/event-stream' } : {}), Authorization: `Bearer ${selected.tokenValue}`, ...buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + account: selected.account, downstreamHeaders: request.headers as Record, }), } @@ -523,7 +523,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { accessToken: refreshed.accessToken, extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, }; - oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + oauth = getOauthInfoFromAccount(selected.account); upstream = await dispatchSelectedRequest(); contentType = upstream.headers.get('content-type') || 'application/json'; } catch { @@ -758,7 +758,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody, downstreamHeaders: request.headers as Record, providerHeaders: buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + account: selected.account, downstreamHeaders: request.headers as Record, }), }); diff --git a/src/server/proxy-core/surfaces/inputFilesSurface.ts b/src/server/proxy-core/surfaces/inputFilesSurface.ts index 46f3d9c..df3c3c8 100644 --- a/src/server/proxy-core/surfaces/inputFilesSurface.ts +++ b/src/server/proxy-core/surfaces/inputFilesSurface.ts @@ -1,114 +1,4 @@ -import type { ProxyResourceOwner } from '../../middleware/auth.js'; -import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js'; -import { - inferInputFileMimeType, - normalizeInputFileBlock, - toOpenAiChatFileBlock, - toResponsesInputFileBlock, - type NormalizedInputFile, -} from '../../transformers/shared/inputFile.js'; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; -} - -function isSupportedFileMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); -} - -export class ProxyInputFileResolutionError extends Error { - statusCode: number; - payload: unknown; - - constructor(statusCode: number, payload: unknown) { - super(typeof payload === 'object' && payload && 'error' in (payload as Record) - ? String(((payload as { error?: { message?: unknown } }).error?.message) || 'input file resolution failed') - : 'input file resolution failed'); - this.name = 'ProxyInputFileResolutionError'; - this.statusCode = statusCode; - this.payload = payload; - } -} - -function buildInvalidRequest(message: string): ProxyInputFileResolutionError { - return new ProxyInputFileResolutionError(400, { - error: { - message, - type: 'invalid_request_error', - }, - }); -} - -function buildNotFound(message: string): ProxyInputFileResolutionError { - return new ProxyInputFileResolutionError(404, { - error: { - message, - type: 'not_found_error', - }, - }); -} - -async function resolveInputFileBlock( - item: Record, - owner: ProxyResourceOwner, -): Promise> { - const normalized = normalizeInputFileBlock(item); - if (!normalized) return item; - - let resolved: NormalizedInputFile = { ...normalized }; - - if (!resolved.fileData && resolved.fileId?.startsWith('file-metapi-')) { - const stored = await getProxyFileByPublicIdForOwner(resolved.fileId, owner); - if (!stored) { - throw buildNotFound(`file not found: ${resolved.fileId}`); - } - resolved = { - filename: stored.filename, - mimeType: stored.mimeType, - fileData: stored.contentBase64, - }; - } - - const inferredMimeType = inferInputFileMimeType(resolved); - if (resolved.fileData && (!inferredMimeType || !isSupportedFileMimeType(inferredMimeType))) { - throw buildInvalidRequest(`unsupported file mime type: ${inferredMimeType || 'application/octet-stream'}`); - } - if (inferredMimeType) { - resolved.mimeType = inferredMimeType; - } - - const type = typeof item.type === 'string' ? item.type : ''; - if (type === 'input_file') { - return toResponsesInputFileBlock(resolved); - } - if (type === 'file') { - return toOpenAiChatFileBlock(resolved); - } - return item; -} - -export async function inlineLocalInputFileReferences( - value: unknown, - owner: ProxyResourceOwner, -): Promise { - if (Array.isArray(value)) { - return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner))); - } - - if (!isRecord(value)) return value; - - const type = typeof value.type === 'string' ? value.type : ''; - if (type === 'input_file' || type === 'file') { - return resolveInputFileBlock(value, owner); - } - - const entries = await Promise.all( - Object.entries(value).map(async ([key, entryValue]) => [key, await inlineLocalInputFileReferences(entryValue, owner)] as const), - ); - return Object.fromEntries(entries); -} +export { + inlineLocalInputFileReferences, + ProxyInputFileResolutionError, +} from '../../services/proxyInputFileResolver.js'; diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 5cc24c9..c14e164 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -1,13 +1,9 @@ import { TextDecoder } from 'node:util'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; -import { isTokenExpiredError } from '../../services/alertRules.js'; -import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { reportProxyAllFailed } from '../../services/alertService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { buildUpstreamEndpointRequest, @@ -16,14 +12,11 @@ import { resolveUpstreamEndpointCandidates, } from '../../routes/proxy/upstreamEndpoint.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js'; -import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; -import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; -import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; import { ProxyInputFileResolutionError, @@ -32,8 +25,7 @@ import { import { buildOauthProviderHeaders, } from '../../services/oauth/service.js'; -import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; -import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; import { collectResponsesFinalPayloadFromSse, @@ -50,8 +42,12 @@ import { summarizeConversationFileInputsInOpenAiBody, summarizeConversationFileInputsInResponsesBody, } from '../capabilities/conversationFileCapabilities.js'; -import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; -import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { + createSurfaceFailureToolkit, + createSurfaceDispatchRequest, + selectSurfaceChannelForAttempt, +} from './sharedSurface.js'; const MAX_RETRIES = 2; @@ -185,18 +181,23 @@ export async function handleOpenAiResponsesSurfaceRequest( if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const failureToolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath, + maxRetries: MAX_RETRIES, + clientContext, + downstreamApiKeyId, + }); const excludeChannelIds: number[] = []; let retryCount = 0; while (retryCount <= MAX_RETRIES) { - let selected = retryCount === 0 - ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) - : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); - - if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); - selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); - } + const selected = await selectSurfaceChannelForAttempt({ + requestedModel, + downstreamPolicy, + excludeChannelIds, + retryCount, + }); if (!selected) { await reportProxyAllFailed({ @@ -211,7 +212,7 @@ export async function handleOpenAiResponsesSurfaceRequest( excludeChannelIds.push(selected.channel.id); const modelName = selected.actualModel || requestedModel; - const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const oauth = getOauthInfoFromAccount(selected.account); const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; const owner = getProxyResourceOwner(request); let normalizedResponsesBody: Record = { @@ -274,7 +275,7 @@ export async function handleOpenAiResponsesSurfaceRequest( }; const buildProviderHeaders = () => ( buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + account: selected.account, downstreamHeaders: request.headers as Record, }) ); @@ -308,19 +309,10 @@ export async function handleOpenAiResponsesSurfaceRequest( runtime: endpointRequest.runtime, }; }; - const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig); - const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( - dispatchRuntimeRequest({ - siteUrl: selected.site.url, - targetUrl, - request: compatibilityRequest, - buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: requestForFetch.headers, - body: JSON.stringify(requestForFetch.body), - }, channelProxyUrl), - }) - ); + const dispatchRequest = createSurfaceDispatchRequest({ + site: selected.site, + accountExtraConfig: selected.account.extraConfig, + }); const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({ isStream: isStream || isCodexSite, requiresNativeResponsesFileUrl, @@ -385,79 +377,34 @@ export async function handleOpenAiResponsesSurfaceRequest( }, shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: (ctx) => { - logProxy( + return failureToolkit.log({ selected, - requestedModel, - 'failed', - ctx.response.status, - Date.now() - startTime, - ctx.errText, + modelRequested: requestedModel, + status: 'failed', + httpStatus: ctx.response.status, + latencyMs: Date.now() - startTime, + errorMessage: ctx.errText, retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - downstreamApiKeyId, - ); + }); }, }); if (!endpointResult.ok) { - const status = endpointResult.status || 502; - const errText = endpointResult.errText || 'unknown error'; - const rawErrText = endpointResult.rawErrText || errText; - tokenRouter.recordFailure(selected.channel.id, { - status, - errorText: rawErrText, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleUpstreamFailure({ selected, requestedModel, - 'failed', - status, - Date.now() - startTime, - errText, + modelName, + status: endpointResult.status || 502, + errText: endpointResult.errText || 'unknown error', + rawErrText: endpointResult.rawErrText, + latencyMs: Date.now() - startTime, retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - downstreamApiKeyId, - ); - await recordOauthQuotaResetHint({ - accountId: selected.account.id, - statusCode: status, - errorText: rawErrText, }); - - if (isTokenExpiredError({ status, message: errText })) { - await reportTokenExpired({ - accountId: selected.account.id, - username: selected.account.username, - siteName: selected.site.name, - detail: `HTTP ${status}`, - }); - } - - if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) { + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - - await reportProxyAllFailed({ - model: requestedModel, - reason: `upstream returned HTTP ${status}`, - }); - return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } const upstream = endpointResult.upstream; @@ -508,13 +455,21 @@ export async function handleOpenAiResponsesSurfaceRequest( try { tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, - usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + await failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'success', + httpStatus: 200, + latencyMs: latency, + errorMessage: null, + retryCount, + promptTokens: usageForLog.promptTokens, + completionTokens: usageForLog.completionTokens, + totalTokens: usageForLog.totalTokens, + estimatedCost, + billingDetails, + upstreamPath: successfulUpstreamPath, + }); } catch (error) { console.error('[responses] post-stream success logging failed:', error); } @@ -569,25 +524,18 @@ export async function handleOpenAiResponsesSurfaceRequest( ); const latency = Date.now() - startTime; if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id, modelName); - logProxy( + await failureToolkit.recordStreamFailure({ selected, requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); return; } @@ -608,68 +556,41 @@ export async function handleOpenAiResponsesSurfaceRequest( const latency = Date.now() - startTime; const failure = detectProxyFailure({ rawText, usage: parsedUsage }); if (failure) { - tokenRouter.recordFailure(selected.channel.id, { - status: failure.status, - errorText: failure.reason, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleDetectedFailure({ selected, requestedModel, - 'failed', - failure.status, - latency, - failure.reason, + modelName, + failure, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); - - if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - - await reportProxyAllFailed({ - model: requestedModel, - reason: failure.reason, - }); - return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } startSseResponse(); const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw); if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id, { - status: 502, - errorText: streamResult.errorMessage, - modelName, - }); - logProxy( + await failureToolkit.recordStreamFailure({ selected, requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); return; } @@ -707,29 +628,19 @@ export async function handleOpenAiResponsesSurfaceRequest( const latency = Date.now() - startTime; if (streamResult.status === 'failed') { - tokenRouter.recordFailure(selected.channel.id, { - status: 502, - errorText: streamResult.errorMessage, - modelName, - }); - logProxy( + await failureToolkit.recordStreamFailure({ selected, requestedModel, - 'failed', - 200, - latency, - streamResult.errorMessage, + modelName, + errorMessage: streamResult.errorMessage, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + runtimeFailureStatus: 502, + }); return; } @@ -775,39 +686,23 @@ export async function handleOpenAiResponsesSurfaceRequest( const parsedUsage = parseProxyUsage(upstreamData); const failure = detectProxyFailure({ rawText, usage: parsedUsage }); if (failure) { - tokenRouter.recordFailure(selected.channel.id, { - status: failure.status, - errorText: failure.reason, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleDetectedFailure({ selected, requestedModel, - 'failed', - failure.status, - latency, - failure.reason, + modelName, + failure, + latencyMs: latency, retryCount, - downstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - 0, - null, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); - if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + upstreamPath: successfulUpstreamPath, + }); + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - - await reportProxyAllFailed({ - model: requestedModel, - reason: failure.reason, - }); - return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } const normalized = openAiResponsesTransformer.transformFinalResponse( upstreamData, @@ -845,106 +740,36 @@ export async function handleOpenAiResponsesSurfaceRequest( tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); recordDownstreamCostUsage(request, estimatedCost); - logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, - resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, - successfulUpstreamPath, - clientContext, - downstreamApiKeyId, - ); + await failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'success', + httpStatus: 200, + latencyMs: latency, + errorMessage: null, + retryCount, + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, + estimatedCost, + billingDetails, + upstreamPath: successfulUpstreamPath, + }); return reply.send(downstreamData); } catch (err: any) { - tokenRouter.recordFailure(selected.channel.id, { - errorText: err?.message, - modelName, - }); - logProxy( + const failureOutcome = await failureToolkit.handleExecutionError({ selected, requestedModel, - 'failed', - 0, - Date.now() - startTime, - err.message, + modelName, + errorMessage: err?.message || 'network failure', + latencyMs: Date.now() - startTime, retryCount, - downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - downstreamApiKeyId, - ); - if (retryCount < MAX_RETRIES) { + }); + if (failureOutcome.action === 'retry') { retryCount += 1; continue; } - await reportProxyAllFailed({ - model: requestedModel, - reason: err.message || 'network failure', - }); - return reply.code(502).send({ - error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, - }); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } } } - -async function logProxy( - selected: any, - modelRequested: string, - status: string, - httpStatus: number, - latencyMs: number, - errorMessage: string | null, - retryCount: number, - downstreamPath: string, - promptTokens = 0, - completionTokens = 0, - totalTokens = 0, - estimatedCost = 0, - billingDetails: unknown = null, - upstreamPath: string | null = null, - clientContext: DownstreamClientContext | null = null, - downstreamApiKeyId: number | null = null, -) { - try { - const createdAt = formatUtcSqlDateTime(new Date()); - const normalizedErrorMessage = composeProxyLogMessage({ - clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic' - ? clientContext.clientKind - : null, - sessionId: clientContext?.sessionId || null, - traceHint: clientContext?.traceHint || null, - downstreamPath, - upstreamPath, - errorMessage, - }); - await insertProxyLog({ - routeId: selected.channel.routeId, - channelId: selected.channel.id, - accountId: selected.account.id, - downstreamApiKeyId, - modelRequested, - modelActual: selected.actualModel, - status, - httpStatus, - latencyMs, - promptTokens, - completionTokens, - totalTokens, - estimatedCost, - billingDetails, - clientFamily: clientContext?.clientKind || null, - clientAppId: clientContext?.clientAppId || null, - clientAppName: clientContext?.clientAppName || null, - clientConfidence: clientContext?.clientConfidence || null, - errorMessage: normalizedErrorMessage, - retryCount, - createdAt, - }); - } catch (error) { - console.warn('[proxy/responses] failed to write proxy log', error); - } -} diff --git a/src/server/proxy-core/surfaces/sharedSurface.test.ts b/src/server/proxy-core/surfaces/sharedSurface.test.ts new file mode 100644 index 0000000..8a0ce8c --- /dev/null +++ b/src/server/proxy-core/surfaces/sharedSurface.test.ts @@ -0,0 +1,471 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EMPTY_DOWNSTREAM_ROUTING_POLICY } from '../../services/downstreamPolicyTypes.js'; + +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const composeProxyLogMessageMock = vi.fn(); +const formatUtcSqlDateTimeMock = vi.fn(); +const insertProxyLogMock = vi.fn(); +const resolveChannelProxyUrlMock = vi.fn(); +const withSiteRecordProxyRequestInitMock = vi.fn(); +const dispatchRuntimeRequestMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const isTokenExpiredErrorMock = vi.fn(); +const shouldRetryProxyRequestMock = vi.fn(); +const recordOauthQuotaResetHintMock = vi.fn(); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../routes/proxy/logPathMeta.js', () => ({ + composeProxyLogMessage: (...args: unknown[]) => composeProxyLogMessageMock(...args), +})); + +vi.mock('../../services/localTimeService.js', () => ({ + formatUtcSqlDateTime: (...args: unknown[]) => formatUtcSqlDateTimeMock(...args), +})); + +vi.mock('../../services/proxyLogStore.js', () => ({ + insertProxyLog: (...args: unknown[]) => insertProxyLogMock(...args), +})); + +vi.mock('../../services/siteProxy.js', () => ({ + resolveChannelProxyUrl: (...args: unknown[]) => resolveChannelProxyUrlMock(...args), + withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args), +})); + +vi.mock('../../routes/proxy/runtimeExecutor.js', () => ({ + dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: (...args: unknown[]) => isTokenExpiredErrorMock(...args), +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: (...args: unknown[]) => shouldRetryProxyRequestMock(...args), +})); + +vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args), +})); + +describe('selectSurfaceChannelForAttempt', () => { + beforeEach(() => { + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + composeProxyLogMessageMock.mockReset(); + formatUtcSqlDateTimeMock.mockReset(); + insertProxyLogMock.mockReset(); + resolveChannelProxyUrlMock.mockReset(); + withSiteRecordProxyRequestInitMock.mockReset(); + dispatchRuntimeRequestMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + isTokenExpiredErrorMock.mockReset(); + shouldRetryProxyRequestMock.mockReset(); + recordOauthQuotaResetHintMock.mockReset(); + }); + + it('refreshes models and retries selectChannel on the first attempt when no channel is available', async () => { + const selected = { channel: { id: 11 } }; + selectChannelMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(selected); + + const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js'); + const result = await selectSurfaceChannelForAttempt({ + requestedModel: 'gpt-5.2', + downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY, + excludeChannelIds: [], + retryCount: 0, + }); + + expect(result).toBe(selected); + expect(selectChannelMock).toHaveBeenCalledTimes(2); + expect(selectNextChannelMock).not.toHaveBeenCalled(); + expect(refreshModelsAndRebuildRoutesMock).toHaveBeenCalledTimes(1); + }); + + it('uses selectNextChannel on retry attempts without refreshing models', async () => { + const selected = { channel: { id: 22 } }; + selectNextChannelMock.mockResolvedValueOnce(selected); + + const { selectSurfaceChannelForAttempt } = await import('./sharedSurface.js'); + const result = await selectSurfaceChannelForAttempt({ + requestedModel: 'gpt-5.2', + downstreamPolicy: EMPTY_DOWNSTREAM_ROUTING_POLICY, + excludeChannelIds: [11], + retryCount: 1, + }); + + expect(result).toBe(selected); + expect(selectChannelMock).not.toHaveBeenCalled(); + expect(selectNextChannelMock).toHaveBeenCalledWith( + 'gpt-5.2', + [11], + EMPTY_DOWNSTREAM_ROUTING_POLICY, + ); + expect(refreshModelsAndRebuildRoutesMock).not.toHaveBeenCalled(); + }); + + it('writes proxy logs through the shared log formatter and store', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + + const { writeSurfaceProxyLog } = await import('./sharedSurface.js'); + await writeSurfaceProxyLog({ + warningScope: 'chat', + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33 }, + actualModel: 'upstream-model', + }, + modelRequested: 'gpt-5.2', + status: 'failed', + httpStatus: 502, + latencyMs: 1200, + errorMessage: 'upstream failed', + retryCount: 1, + downstreamPath: '/v1/chat/completions', + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + estimatedCost: 0.42, + billingDetails: { source: 'test' }, + upstreamPath: '/v1/responses', + clientContext: { + clientKind: 'codex', + clientAppId: 'app-id', + clientAppName: 'App', + clientConfidence: 'high', + sessionId: 'sess-1', + traceHint: 'trace-1', + }, + downstreamApiKeyId: 44, + }); + + expect(composeProxyLogMessageMock).toHaveBeenCalledWith({ + clientKind: 'codex', + sessionId: 'sess-1', + traceHint: 'trace-1', + downstreamPath: '/v1/chat/completions', + upstreamPath: '/v1/responses', + errorMessage: 'upstream failed', + }); + expect(insertProxyLogMock).toHaveBeenCalledWith({ + routeId: 22, + channelId: 11, + accountId: 33, + downstreamApiKeyId: 44, + modelRequested: 'gpt-5.2', + modelActual: 'upstream-model', + status: 'failed', + httpStatus: 502, + latencyMs: 1200, + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + estimatedCost: 0.42, + billingDetails: { source: 'test' }, + clientFamily: 'codex', + clientAppId: 'app-id', + clientAppName: 'App', + clientConfidence: 'high', + errorMessage: 'normalized error', + retryCount: 1, + createdAt: '2026-03-21 22:00:00', + }); + }); + + it('builds runtime dispatch requests with site proxy initialization', async () => { + const site = { url: 'https://upstream.example.com' }; + const request = { + endpoint: 'responses', + path: '/v1/responses', + headers: { authorization: 'Bearer test' }, + body: { model: 'gpt-5.2', input: 'hello' }, + runtime: { executor: 'default' }, + }; + resolveChannelProxyUrlMock.mockReturnValue('http://proxy.example.com'); + withSiteRecordProxyRequestInitMock.mockImplementation(async (_site, init, proxyUrl) => ({ + ...init, + proxyUrl, + })); + dispatchRuntimeRequestMock.mockResolvedValue('ok'); + + const { createSurfaceDispatchRequest } = await import('./sharedSurface.js'); + const dispatchRequest = createSurfaceDispatchRequest({ + site, + accountExtraConfig: '{"proxyUrl":"http://proxy.example.com"}', + }); + const result = await dispatchRequest(request, 'https://target.example.com/v1/responses'); + + expect(result).toBe('ok'); + expect(resolveChannelProxyUrlMock).toHaveBeenCalledWith( + site, + '{"proxyUrl":"http://proxy.example.com"}', + ); + expect(dispatchRuntimeRequestMock).toHaveBeenCalledTimes(1); + const dispatchArg = dispatchRuntimeRequestMock.mock.calls[0]?.[0]; + expect(dispatchArg.siteUrl).toBe('https://upstream.example.com'); + expect(dispatchArg.targetUrl).toBe('https://target.example.com/v1/responses'); + expect(dispatchArg.request).toBe(request); + return dispatchArg.buildInit('https://target.example.com/v1/responses', { + headers: { authorization: 'Bearer test' }, + body: { model: 'gpt-5.2', input: 'hello' }, + }).then((init: Record) => { + expect(withSiteRecordProxyRequestInitMock).toHaveBeenCalledWith(site, { + method: 'POST', + headers: { authorization: 'Bearer test' }, + body: JSON.stringify({ model: 'gpt-5.2', input: 'hello' }), + }, 'http://proxy.example.com'); + expect(init).toEqual({ + method: 'POST', + headers: { authorization: 'Bearer test' }, + body: JSON.stringify({ model: 'gpt-5.2', input: 'hello' }), + proxyUrl: 'http://proxy.example.com', + }); + }); + }); + + it('retries retryable upstream HTTP failures through the shared failure toolkit', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(true); + isTokenExpiredErrorMock.mockReturnValue(false); + recordOauthQuotaResetHintMock.mockResolvedValue(null); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath: '/v1/chat/completions', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: 44, + }); + + const result = await toolkit.handleUpstreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + status: 429, + errText: 'quota exceeded', + rawErrText: '{"error":"quota exceeded"}', + latencyMs: 1200, + retryCount: 0, + }); + + expect(result).toEqual({ action: 'retry' }); + expect(recordFailureMock).toHaveBeenCalledWith(11, { + status: 429, + errorText: '{"error":"quota exceeded"}', + modelName: 'upstream-model', + }); + expect(recordOauthQuotaResetHintMock).toHaveBeenCalledWith({ + accountId: 33, + statusCode: 429, + errorText: '{"error":"quota exceeded"}', + }); + expect(reportProxyAllFailedMock).not.toHaveBeenCalled(); + expect(insertProxyLogMock).toHaveBeenCalledWith(expect.objectContaining({ + channelId: 11, + accountId: 33, + downstreamApiKeyId: 44, + modelRequested: 'gpt-5.2', + modelActual: 'upstream-model', + status: 'failed', + httpStatus: 429, + latencyMs: 1200, + errorMessage: 'normalized error', + retryCount: 0, + })); + }); + + it('returns a terminal upstream error response and reports token expiration when retries stop', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(false); + isTokenExpiredErrorMock.mockReturnValue(true); + recordOauthQuotaResetHintMock.mockResolvedValue(null); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath: '/v1/responses', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + const result = await toolkit.handleUpstreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + status: 401, + errText: 'expired token', + rawErrText: 'expired token', + latencyMs: 900, + retryCount: 2, + }); + + expect(result).toEqual({ + action: 'respond', + status: 401, + payload: { + error: { + message: 'expired token', + type: 'upstream_error', + }, + }, + }); + expect(reportTokenExpiredMock).toHaveBeenCalledWith({ + accountId: 33, + username: 'oauth-user', + siteName: 'Codex OAuth', + detail: 'HTTP 401', + }); + expect(reportProxyAllFailedMock).toHaveBeenCalledWith({ + model: 'gpt-5.2', + reason: 'upstream returned HTTP 401', + }); + }); + + it('handles detected proxy failures through the shared failure toolkit', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(false); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath: '/v1/chat/completions', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + const result = await toolkit.handleDetectedFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + failure: { + status: 500, + reason: 'upstream failure', + }, + latencyMs: 700, + retryCount: 2, + promptTokens: 12, + completionTokens: 4, + totalTokens: 16, + upstreamPath: '/v1/responses', + }); + + expect(result).toEqual({ + action: 'respond', + status: 500, + payload: { + error: { + message: 'upstream failure', + type: 'upstream_error', + }, + }, + }); + expect(recordFailureMock).toHaveBeenCalledWith(11, { + status: 500, + errorText: 'upstream failure', + modelName: 'upstream-model', + }); + expect(reportProxyAllFailedMock).toHaveBeenCalledWith({ + model: 'gpt-5.2', + reason: 'upstream failure', + }); + expect(recordOauthQuotaResetHintMock).not.toHaveBeenCalled(); + }); + + it('returns a terminal 502 for exhausted network failures through the shared failure toolkit', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath: '/v1/responses', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + const result = await toolkit.handleExecutionError({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + errorMessage: 'socket hang up', + latencyMs: 650, + retryCount: 2, + }); + + expect(result).toEqual({ + action: 'respond', + status: 502, + payload: { + error: { + message: 'Upstream error: socket hang up', + type: 'upstream_error', + }, + }, + }); + expect(recordFailureMock).toHaveBeenCalledWith(11, { + errorText: 'socket hang up', + modelName: 'upstream-model', + }); + expect(reportProxyAllFailedMock).toHaveBeenCalledWith({ + model: 'gpt-5.2', + reason: 'socket hang up', + }); + }); +}); diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts new file mode 100644 index 0000000..31baffd --- /dev/null +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -0,0 +1,394 @@ +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import type { SiteProxyConfigLike } from '../../services/siteProxy.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import type { DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js'; +import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; +import { isTokenExpiredError } from '../../services/alertRules.js'; +import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; +import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; +import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; +import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; +import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; + +type SelectedChannel = Awaited>; +type SurfaceWarningScope = 'chat' | 'responses'; + +type SurfaceSelectedChannel = { + channel: { routeId: number | null; id: number }; + account: { id: number; username?: string | null }; + site: { name?: string | null }; + actualModel?: string | null; +}; + +type SurfaceFailureResponse = { + action: 'respond'; + status: number; + payload: { + error: { + message: string; + type: 'upstream_error'; + }; + }; +}; + +type SurfaceFailureOutcome = + | { action: 'retry' } + | SurfaceFailureResponse; + +export async function selectSurfaceChannelForAttempt(input: { + requestedModel: string; + downstreamPolicy: DownstreamRoutingPolicy; + excludeChannelIds: number[]; + retryCount: number; +}): Promise { + let selected = input.retryCount === 0 + ? await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy) + : await tokenRouter.selectNextChannel( + input.requestedModel, + input.excludeChannelIds, + input.downstreamPolicy, + ); + + if (!selected && input.retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy); + } + + return selected; +} + +export async function writeSurfaceProxyLog(input: { + warningScope: string; + selected: { + channel: { routeId: number | null; id: number | null }; + account: { id: number | null }; + actualModel?: string | null; + }; + modelRequested: string; + status: string; + httpStatus: number; + latencyMs: number; + errorMessage: string | null; + retryCount: number; + downstreamPath: string; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + estimatedCost?: number; + billingDetails?: unknown; + upstreamPath?: string | null; + clientContext?: DownstreamClientContext | null; + downstreamApiKeyId?: number | null; +}): Promise { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + clientKind: input.clientContext?.clientKind && input.clientContext.clientKind !== 'generic' + ? input.clientContext.clientKind + : null, + sessionId: input.clientContext?.sessionId || null, + traceHint: input.clientContext?.traceHint || null, + downstreamPath: input.downstreamPath, + upstreamPath: input.upstreamPath || null, + errorMessage: input.errorMessage, + }); + await insertProxyLog({ + routeId: input.selected.channel.routeId, + channelId: input.selected.channel.id, + accountId: input.selected.account.id, + downstreamApiKeyId: input.downstreamApiKeyId ?? null, + modelRequested: input.modelRequested, + modelActual: input.selected.actualModel ?? null, + status: input.status, + httpStatus: input.httpStatus, + latencyMs: input.latencyMs, + promptTokens: input.promptTokens ?? 0, + completionTokens: input.completionTokens ?? 0, + totalTokens: input.totalTokens ?? 0, + estimatedCost: input.estimatedCost ?? 0, + billingDetails: input.billingDetails ?? null, + clientFamily: input.clientContext?.clientKind || null, + clientAppId: input.clientContext?.clientAppId || null, + clientAppName: input.clientContext?.clientAppName || null, + clientConfidence: input.clientContext?.clientConfidence || null, + errorMessage: normalizedErrorMessage, + retryCount: input.retryCount, + createdAt, + }); + } catch (error) { + console.warn(`[proxy/${input.warningScope}] failed to write proxy log`, error); + } +} + +export function createSurfaceDispatchRequest(input: { + site: SiteProxyConfigLike & { url: string }; + accountExtraConfig?: string | null; +}) { + const channelProxyUrl = resolveChannelProxyUrl(input.site, input.accountExtraConfig); + return ( + request: BuiltEndpointRequest, + targetUrl?: string, + ) => ( + dispatchRuntimeRequest({ + siteUrl: input.site.url, + targetUrl, + request, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(input.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }, channelProxyUrl), + }) + ); +} + +export function createSurfaceFailureToolkit(input: { + warningScope: SurfaceWarningScope; + downstreamPath: string; + maxRetries: number; + clientContext?: DownstreamClientContext | null; + downstreamApiKeyId?: number | null; +}) { + const log = async (args: { + selected: SurfaceSelectedChannel; + modelRequested: string; + status: string; + httpStatus: number; + latencyMs: number; + errorMessage: string | null; + retryCount: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + estimatedCost?: number; + billingDetails?: unknown; + upstreamPath?: string | null; + }) => { + await writeSurfaceProxyLog({ + warningScope: input.warningScope, + selected: args.selected, + modelRequested: args.modelRequested, + status: args.status, + httpStatus: args.httpStatus, + latencyMs: args.latencyMs, + errorMessage: args.errorMessage, + retryCount: args.retryCount, + downstreamPath: input.downstreamPath, + promptTokens: args.promptTokens, + completionTokens: args.completionTokens, + totalTokens: args.totalTokens, + estimatedCost: args.estimatedCost, + billingDetails: args.billingDetails, + upstreamPath: args.upstreamPath, + clientContext: input.clientContext, + downstreamApiKeyId: input.downstreamApiKeyId, + }); + }; + + const maybeRetry = (retryCount: number) => retryCount < input.maxRetries + ? { action: 'retry' as const } + : null; + + return { + log, + async handleUpstreamFailure(args: { + selected: SurfaceSelectedChannel; + requestedModel: string; + modelName: string; + status: number; + errText: string; + rawErrText?: string | null; + latencyMs: number; + retryCount: number; + }): Promise { + const rawErrText = args.rawErrText || args.errText; + tokenRouter.recordFailure(args.selected.channel.id, { + status: args.status, + errorText: rawErrText, + modelName: args.modelName, + }); + await log({ + selected: args.selected, + modelRequested: args.requestedModel, + status: 'failed', + httpStatus: args.status, + latencyMs: args.latencyMs, + errorMessage: args.errText, + retryCount: args.retryCount, + }); + await recordOauthQuotaResetHint({ + accountId: args.selected.account.id, + statusCode: args.status, + errorText: rawErrText, + }); + + if (isTokenExpiredError({ status: args.status, message: args.errText })) { + await reportTokenExpired({ + accountId: args.selected.account.id, + username: args.selected.account.username, + siteName: args.selected.site.name, + detail: `HTTP ${args.status}`, + }); + } + + if (shouldRetryProxyRequest(args.status, args.errText)) { + const retry = maybeRetry(args.retryCount); + if (retry) return retry; + } + + await reportProxyAllFailed({ + model: args.requestedModel, + reason: `upstream returned HTTP ${args.status}`, + }); + + return { + action: 'respond', + status: args.status, + payload: { + error: { + message: args.errText, + type: 'upstream_error', + }, + }, + }; + }, + + async handleDetectedFailure(args: { + selected: SurfaceSelectedChannel; + requestedModel: string; + modelName: string; + failure: { status: number; reason: string }; + latencyMs: number; + retryCount: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + upstreamPath?: string | null; + }): Promise { + tokenRouter.recordFailure(args.selected.channel.id, { + status: args.failure.status, + errorText: args.failure.reason, + modelName: args.modelName, + }); + await log({ + selected: args.selected, + modelRequested: args.requestedModel, + status: 'failed', + httpStatus: args.failure.status, + latencyMs: args.latencyMs, + errorMessage: args.failure.reason, + retryCount: args.retryCount, + promptTokens: args.promptTokens, + completionTokens: args.completionTokens, + totalTokens: args.totalTokens, + upstreamPath: args.upstreamPath, + }); + + if (shouldRetryProxyRequest(args.failure.status, args.failure.reason)) { + const retry = maybeRetry(args.retryCount); + if (retry) return retry; + } + + await reportProxyAllFailed({ + model: args.requestedModel, + reason: args.failure.reason, + }); + + return { + action: 'respond', + status: args.failure.status, + payload: { + error: { + message: args.failure.reason, + type: 'upstream_error', + }, + }, + }; + }, + + async handleExecutionError(args: { + selected: SurfaceSelectedChannel; + requestedModel: string; + modelName: string; + errorMessage: string; + latencyMs: number; + retryCount: number; + }): Promise { + tokenRouter.recordFailure(args.selected.channel.id, { + errorText: args.errorMessage, + modelName: args.modelName, + }); + await log({ + selected: args.selected, + modelRequested: args.requestedModel, + status: 'failed', + httpStatus: 0, + latencyMs: args.latencyMs, + errorMessage: args.errorMessage, + retryCount: args.retryCount, + }); + + const retry = maybeRetry(args.retryCount); + if (retry) return retry; + + await reportProxyAllFailed({ + model: args.requestedModel, + reason: args.errorMessage || 'network failure', + }); + + return { + action: 'respond', + status: 502, + payload: { + error: { + message: `Upstream error: ${args.errorMessage || 'network failure'}`, + type: 'upstream_error', + }, + }, + }; + }, + + async recordStreamFailure(args: { + selected: SurfaceSelectedChannel; + requestedModel: string; + modelName: string; + errorMessage: string | null; + latencyMs: number; + retryCount: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + upstreamPath?: string | null; + httpStatus?: number; + runtimeFailureStatus?: number | null; + }) { + const errorMessage = args.errorMessage || 'stream processing failed'; + if (typeof args.runtimeFailureStatus === 'number') { + tokenRouter.recordFailure(args.selected.channel.id, { + status: args.runtimeFailureStatus, + errorText: errorMessage, + modelName: args.modelName, + }); + } else { + tokenRouter.recordFailure(args.selected.channel.id, args.modelName); + } + await log({ + selected: args.selected, + modelRequested: args.requestedModel, + status: 'failed', + httpStatus: args.httpStatus ?? 200, + latencyMs: args.latencyMs, + errorMessage, + retryCount: args.retryCount, + promptTokens: args.promptTokens, + completionTokens: args.completionTokens, + totalTokens: args.totalTokens, + upstreamPath: args.upstreamPath, + }); + }, + }; +} diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 2bf36b4..548a3d1 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -4,7 +4,6 @@ import { db, schema } from '../../db/index.js'; import { ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING, ACCOUNT_TOKEN_VALUE_STATUS_READY, - ensureDefaultTokenForAccount, isMaskedPendingAccountToken, isMaskedTokenValue, isUsableAccountToken, @@ -14,17 +13,17 @@ import { repairDefaultToken, resolveAccountTokenValueStatus, setDefaultToken, - syncTokensFromUpstream, } from '../../services/accountTokenService.js'; import { getAdapter } from '../../services/platforms/index.js'; import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { withAccountProxyOverride } from '../../services/siteProxy.js'; +import { type ModelRefreshResult } from '../../services/modelService.js'; import { - rebuildTokenRoutesFromAvailability, - refreshModelsForAccount, - type ModelRefreshResult, -} from '../../services/modelService.js'; + type CoverageBatchRebuildResult, + convergeAccountMutation, + refreshAccountCoverageBatch, +} from '../../services/accountMutationWorkflow.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -65,9 +64,7 @@ type CoverageRefreshFailureItem = { }; type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem; -type CoverageRefreshRebuildResult = - | { success: true; result: Awaited> } - | { success: false; error: string }; +type CoverageRefreshRebuildResult = CoverageBatchRebuildResult; const TOKEN_SYNC_TIMEOUT_MS = 15_000; const SYNC_ALL_BATCH_SIZE = 3; @@ -231,7 +228,11 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise 0) { return { ...base, @@ -390,49 +395,26 @@ async function executeSyncAllAccountTokens() { } async function refreshCoverageForAccounts(accountIds: number[]) { - const uniqueAccountIds = Array.from(new Set( - accountIds.filter((id) => Number.isFinite(id) && id > 0), - )); + const result = await refreshAccountCoverageBatch({ + accountIds, + batchSize: SYNC_ALL_BATCH_SIZE, + mapFailure: buildCoverageRefreshFailureItem, + }); - if (uniqueAccountIds.length === 0) { - return { refresh: [], rebuild: null }; + result.refresh.forEach((item) => { + if ((item as CoverageRefreshFailureItem).reason === 'coverage_refresh_failed') { + const failed = item as CoverageRefreshFailureItem; + console.warn(`[account-tokens] coverage refresh failed for account ${failed.accountId}: ${failed.errorMessage}`); + } + }); + if (result.rebuild && !result.rebuild.success) { + console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${result.rebuild.error}`); } - const refresh: CoverageRefreshItem[] = []; - for (let offset = 0; offset < uniqueAccountIds.length; offset += SYNC_ALL_BATCH_SIZE) { - const batch = uniqueAccountIds.slice(offset, offset + SYNC_ALL_BATCH_SIZE); - const settled = await Promise.allSettled( - batch.map(async (accountId) => refreshModelsForAccount(accountId)), - ); - settled.forEach((result, index) => { - if (result.status === 'fulfilled') { - refresh.push(result.value); - return; - } - - const accountId = batch[index] || 0; - const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason || 'coverage refresh failed'); - refresh.push(buildCoverageRefreshFailureItem(accountId, errorMessage)); - console.warn(`[account-tokens] coverage refresh failed for account ${accountId}: ${errorMessage}`); - }); - } - - let rebuild: CoverageRefreshRebuildResult | null = null; - try { - rebuild = { - success: true, - result: await rebuildTokenRoutesFromAvailability(), - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed'); - rebuild = { - success: false, - error: errorMessage, - }; - console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${errorMessage}`); - } - - return { refresh, rebuild }; + return { + refresh: result.refresh as CoverageRefreshItem[], + rebuild: result.rebuild as CoverageRefreshRebuildResult | null, + }; } function buildCoverageRefreshFailureItem( diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 5ee9b7e..04f6045 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -3,8 +3,8 @@ import { db, schema, runtimeDbDialect } from '../../db/index.js'; import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { refreshBalance } from '../../services/balanceService.js'; import { getAdapter } from '../../services/platforms/index.js'; -import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js'; -import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js'; +import { rebuildTokenRoutesFromAvailability } from '../../services/modelService.js'; +import { convergeAccountMutation } from '../../services/accountMutationWorkflow.js'; import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, @@ -153,37 +153,29 @@ async function initializeAccountInBackground({ rebuiltRoutes: false, }; - if (tokenType === 'session' && apiToken) { - try { - await ensureDefaultTokenForAccount(accountId, apiToken, { name: 'default', source: 'manual' }); - } catch {} - } - + let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = []; if (tokenType === 'session' && accessToken) { try { const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId); summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0; - if (summary.syncedTokenCount > 0) { - await syncTokensFromUpstream(accountId, syncedTokens); - } + fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : []; } catch {} } - if (tokenType === 'session') { - try { - await refreshBalance(accountId); - summary.refreshedBalance = true; - } catch {} - } - - if (skipModelFetch !== true) { - try { - await refreshModelsForAccount(accountId); - summary.refreshedModels = true; - await rebuildTokenRoutesFromAvailability(); - summary.rebuiltRoutes = true; - } catch {} - } + const convergence = await convergeAccountMutation({ + accountId, + preferredApiToken: tokenType === 'session' ? apiToken : null, + defaultTokenSource: 'manual', + ensurePreferredTokenBeforeSync: tokenType === 'session', + upstreamTokens: fetchedUpstreamTokens, + refreshBalance: tokenType === 'session', + refreshModels: skipModelFetch !== true, + rebuildRoutes: skipModelFetch !== true, + continueOnError: true, + }); + summary.refreshedBalance = convergence.refreshedBalance; + summary.refreshedModels = convergence.refreshedModels; + summary.rebuiltRoutes = convergence.rebuiltRoutes; return summary; } @@ -612,22 +604,16 @@ export async function accountsRoutes(app: FastifyInstance) { return { success: false, message: 'account create failed' }; } - if (apiTokens.length > 0) { - try { - await syncTokensFromUpstream(result.id, apiTokens); - } catch { } - } else if (preferredApiToken) { - try { - await ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' }); - } catch { } - } - - // Auto-refresh balance - try { await refreshBalance(result.id); } catch { } - try { - await refreshModelsForAccount(result.id); - await rebuildTokenRoutesFromAvailability(); - } catch { } + await convergeAccountMutation({ + accountId: result.id, + preferredApiToken, + defaultTokenSource: 'sync', + upstreamTokens: apiTokens, + refreshBalance: true, + refreshModels: true, + rebuildRoutes: true, + continueOnError: true, + }); const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); return { @@ -1043,19 +1029,15 @@ export async function accountsRoutes(app: FastifyInstance) { await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run(); - if (nextApiToken) { - try { - await ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' }); - } catch {} - } - - try { - await refreshBalance(accountId); - } catch {} - try { - await refreshModelsForAccount(accountId); - await rebuildTokenRoutesFromAvailability(); - } catch {} + await convergeAccountMutation({ + accountId, + preferredApiToken: nextApiToken, + defaultTokenSource: 'sync', + refreshBalance: true, + refreshModels: true, + rebuildRoutes: true, + continueOnError: true, + }); const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get(); return { @@ -1332,16 +1314,14 @@ export async function accountsRoutes(app: FastifyInstance) { ? explicitNextMode : (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey'); - if (nextCredentialMode !== 'apikey' && typeof updates.apiToken === 'string' && updates.apiToken.trim()) { - try { - await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' }); - } catch { } - } - - try { - await refreshModelsForAccount(id); - await rebuildTokenRoutesFromAvailability(); - } catch { } + await convergeAccountMutation({ + accountId: id, + preferredApiToken: nextCredentialMode !== 'apikey' ? updates.apiToken : null, + defaultTokenSource: 'manual', + refreshModels: true, + rebuildRoutes: true, + continueOnError: true, + }); return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get(); }); diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 6358f03..4229d4d 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -243,11 +243,11 @@ describe('oauth routes', { timeout: 15_000 }, () => { const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); expect(parsed.oauth).toMatchObject({ - provider: 'antigravity', email: 'antigravity-user@example.com', - projectId: 'antigravity-auto-project', refreshToken: 'antigravity-refresh-token', }); + expect(parsed.oauth).not.toHaveProperty('provider'); + expect(parsed.oauth).not.toHaveProperty('projectId'); expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); @@ -419,14 +419,15 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ credentialMode: 'session', oauth: { - provider: 'codex', - accountId: 'chatgpt-account-123', email: 'codex-user@example.com', planType: 'plus', refreshToken: 'oauth-refresh-token', idToken: jwt, }, }); + const codexStoredOauth = JSON.parse(accounts[0]?.extraConfig || '{}').oauth; + expect(codexStoredOauth).not.toHaveProperty('provider'); + expect(codexStoredOauth).not.toHaveProperty('accountId'); const models = await db.select().from(schema.modelAvailability).all(); const modelNames = models.map((row) => row.modelName); @@ -932,11 +933,11 @@ describe('oauth routes', { timeout: 15_000 }, () => { const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); expect(parsed.oauth).toMatchObject({ - provider: 'gemini-cli', email: 'gemini-user@example.com', - projectId: 'first-project-id', refreshToken: 'gemini-refresh-token', }); + expect(parsed.oauth).not.toHaveProperty('provider'); + expect(parsed.oauth).not.toHaveProperty('projectId'); expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects'); expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); @@ -1048,8 +1049,9 @@ describe('oauth routes', { timeout: 15_000 }, () => { const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); expect(parsed.oauth).toMatchObject({ - projectId: 'gen-lang-client-0123456789', + refreshToken: 'gemini-refresh-token', }); + expect(parsed.oauth).not.toHaveProperty('projectId'); expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); @@ -1525,12 +1527,10 @@ describe('oauth routes', { timeout: 15_000 }, () => { items: expect.arrayContaining([ expect.objectContaining({ provider: 'codex', - email: 'team-user@example.com', accountKey: 'chatgpt-team-account-a', }), expect.objectContaining({ provider: 'codex', - email: 'team-user@example.com', accountKey: 'chatgpt-team-account-b', }), ]), diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 6a5ff5f..9b11928 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -9,12 +9,13 @@ import { import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; -import { - clearRouteDecisionSnapshot, - clearRouteDecisionSnapshots, - parseRouteDecisionSnapshot, - saveRouteDecisionSnapshots, -} from '../../services/routeDecisionSnapshotStore.js'; +import { + clearRouteDecisionSnapshot, + clearRouteDecisionSnapshots, + parseRouteDecisionSnapshot, + saveRouteDecisionSnapshots, +} from '../../services/routeDecisionSnapshotStore.js'; +import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js'; function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); @@ -23,14 +24,13 @@ function isExactModelPattern(modelPattern: string): boolean { return !/[\*\?]/.test(normalized); } -type RouteMode = 'pattern' | 'explicit_group'; type RouteRow = typeof schema.tokenRoutes.$inferSelect & { routeMode: RouteMode; sourceRouteIds: number[]; }; function normalizeRouteMode(routeMode: unknown): RouteMode { - return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; + return normalizeTokenRouteMode(routeMode); } function isExplicitGroupRoute(route: Pick | Pick): boolean { diff --git a/src/server/routes/proxy/downstreamClientContext.ts b/src/server/routes/proxy/downstreamClientContext.ts index 80fdf94..5f4edad 100644 --- a/src/server/routes/proxy/downstreamClientContext.ts +++ b/src/server/routes/proxy/downstreamClientContext.ts @@ -2,7 +2,10 @@ import { extractClaudeCodeSessionId as extractClaudeCodeSessionIdViaProfile } fr import { detectCliProfile, } from '../../proxy-core/cliProfiles/registry.js'; -import { isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile } from '../../proxy-core/cliProfiles/codexProfile.js'; +import { + detectCodexOfficialClientApp as detectCodexOfficialClientAppViaProfile, + isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile, +} from '../../proxy-core/cliProfiles/codexProfile.js'; import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js'; export type DownstreamClientKind = CliProfileId; @@ -43,58 +46,6 @@ type DownstreamProtocolClientApp = { clientConfidence: DownstreamClientConfidence; }; -type HeaderPrefixMatcherRule = { - id: string; - name: string; - userAgentPrefixes?: string[]; - originatorPrefixes?: string[]; -}; - -const codexOfficialClientAppRules: HeaderPrefixMatcherRule[] = [ - { - id: 'codex_cli_rs', - name: 'Codex CLI', - userAgentPrefixes: ['codex_cli_rs/'], - originatorPrefixes: ['codex_cli_rs'], - }, - { - id: 'codex_vscode', - name: 'Codex VSCode', - userAgentPrefixes: ['codex_vscode/'], - originatorPrefixes: ['codex_vscode'], - }, - { - id: 'codex_app', - name: 'Codex App', - userAgentPrefixes: ['codex_app/'], - originatorPrefixes: ['codex_app'], - }, - { - id: 'codex_chatgpt_desktop', - name: 'Codex Desktop', - userAgentPrefixes: ['codex_chatgpt_desktop/', 'codex desktop/'], - originatorPrefixes: ['codex_chatgpt_desktop', 'codex desktop'], - }, - { - id: 'codex_atlas', - name: 'Codex Atlas', - userAgentPrefixes: ['codex_atlas/'], - originatorPrefixes: ['codex_atlas'], - }, - { - id: 'codex_exec', - name: 'Codex Exec', - userAgentPrefixes: ['codex_exec/'], - originatorPrefixes: ['codex_exec'], - }, - { - id: 'codex_sdk_ts', - name: 'Codex SDK TS', - userAgentPrefixes: ['codex_sdk_ts/'], - originatorPrefixes: ['codex_sdk_ts'], - }, -]; - function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -138,10 +89,6 @@ function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedF return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected)); } -function headerMatchesPrefixes(headers: NormalizedClientHeaders, key: string, prefixes: string[]): boolean { - return prefixes.some((prefix) => headerIncludes(headers, key, prefix)); -} - function normalizeClientDisplayName(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return null; @@ -276,25 +223,6 @@ function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): Downs return null; } -function detectCodexOfficialClientApp(headers: NormalizedClientHeaders): DownstreamProtocolClientApp | null { - for (const rule of codexOfficialClientAppRules) { - const matchesOriginator = Array.isArray(rule.originatorPrefixes) - && headerMatchesPrefixes(headers, 'originator', rule.originatorPrefixes); - const matchesUserAgent = Array.isArray(rule.userAgentPrefixes) - && headerMatchesPrefixes(headers, 'user-agent', rule.userAgentPrefixes); - - if (!matchesOriginator && !matchesUserAgent) continue; - - return { - clientAppId: rule.id, - clientAppName: rule.name, - clientConfidence: 'exact', - }; - } - - return null; -} - function detectProtocolClientApp(input: { clientKind: DownstreamClientKind; headers?: Record; @@ -313,8 +241,13 @@ function detectProtocolClientApp(input: { clientConfidence: 'exact', }; case 'codex': { - const headers = normalizeHeaders(input.headers); - return detectCodexOfficialClientApp(headers) || { + const clientApp = detectCodexOfficialClientAppViaProfile(input.headers); + return (clientApp + ? { + ...clientApp, + clientConfidence: 'exact' as const, + } + : null) || { clientAppId: 'codex', clientAppName: 'Codex', clientConfidence: 'heuristic', diff --git a/src/server/routes/proxy/responsesWebsocket.ts b/src/server/routes/proxy/responsesWebsocket.ts index 70531a0..28dae3a 100644 --- a/src/server/routes/proxy/responsesWebsocket.ts +++ b/src/server/routes/proxy/responsesWebsocket.ts @@ -562,9 +562,7 @@ async function handleResponsesWebsocketConnection( ...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}), }; const providerHeaders = buildOauthProviderHeaders({ - extraConfig: typeof codexWebsocketChannel.account.extraConfig === 'string' - ? codexWebsocketChannel.account.extraConfig - : null, + account: codexWebsocketChannel.account, downstreamHeaders, }); const prepared = buildUpstreamEndpointRequest({ diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 6c91a0f..2441004 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { randomUUID } from 'node:crypto'; import { rankConversationFileEndpoints, type ConversationFileInputSummary, @@ -27,6 +27,14 @@ import { promoteResponsesCandidateAfterLegacyChatError, shouldPreferResponsesAfterLegacyChatError, } from '../../transformers/shared/endpointCompatibility.js'; +import { + buildClaudeRuntimeHeaders, + buildCodexRuntimeHeaders, + buildGeminiCliRuntimeHeaders, + buildGeminiCliUserAgent, + getInputHeader, + headerValueToString, +} from '../../proxy-core/providers/headerUtils.js'; export { buildMinimalJsonHeadersForCompatibility, isEndpointDispatchDeniedError, @@ -104,23 +112,6 @@ function isClaudeFamilyModel(modelName: string): boolean { return normalized === 'claude' || normalized.startsWith('claude-') || normalized.includes('claude'); } -function headerValueToString(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed || null; - } - - if (Array.isArray(value)) { - for (const item of value) { - if (typeof item !== 'string') continue; - const trimmed = item.trim(); - if (trimmed) return trimmed; - } - } - - return null; -} - const HOP_BY_HOP_HEADERS = new Set([ 'connection', 'keep-alive', @@ -146,11 +137,7 @@ const BLOCKED_PASSTHROUGH_HEADERS = new Set([ 'sec-websocket-extensions', ]); -const CODEX_CLIENT_VERSION = '0.101.0'; -const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; -const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)'; -const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; function shouldSkipPassthroughHeader(key: string): boolean { return HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key); @@ -222,81 +209,6 @@ function extractResponsesPassthroughHeaders( return forwarded; } -function getInputHeader( - headers: Record | Record | undefined, - key: string, -): string | null { - if (!headers) return null; - for (const [candidateKey, candidateValue] of Object.entries(headers)) { - if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; - return headerValueToString(candidateValue); - } - return null; -} - -function parseGeminiCliUserAgentRuntime(userAgent: string | null): { - version: string; - platform: string; - arch: string; -} | null { - if (!userAgent) return null; - const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim()); - if (!match) return null; - return { - version: match[1] || '0.31.0', - platform: match[2] || 'win32', - arch: match[3] || 'x64', - }; -} - -function buildGeminiCLIUserAgent(modelName: string, existingUserAgent?: string | null): string { - const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null); - const version = parsed?.version || '0.31.0'; - const platform = parsed?.platform || 'win32'; - const arch = parsed?.arch || 'x64'; - const effectiveModel = asTrimmedString(modelName) || 'unknown'; - return `GeminiCLI/${version}/${effectiveModel} (${platform}; ${arch})`; -} - -function uuidFromSeed(seed: string): string { - const hash = createHash('sha1').update(seed).digest(); - const bytes = new Uint8Array(hash.subarray(0, 16)); - bytes[6] = (bytes[6]! & 0x0f) | 0x50; - bytes[8] = (bytes[8]! & 0x3f) | 0x80; - const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); - return [ - hex.slice(0, 8), - hex.slice(8, 12), - hex.slice(12, 16), - hex.slice(16, 20), - hex.slice(20, 32), - ].join('-'); -} - -function mergeClaudeBetaHeader( - explicitValue: string | null, - extraBetas: string[] = [], -): string { - const source = explicitValue || CLAUDE_DEFAULT_BETA_HEADER; - const seen = new Set(); - const merged: string[] = []; - for (const entry of source.split(',')) { - const normalized = entry.trim(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - merged.push(normalized); - } - if (!explicitValue) { - for (const entry of extraBetas) { - const normalized = entry.trim(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - merged.push(normalized); - } - } - return merged.join(','); -} - function extractClaudeBetasFromBody(body: Record): { body: Record; betas: string[]; @@ -327,80 +239,6 @@ function extractClaudeBetasFromBody(body: Record): { }; } -function buildCodexRuntimeHeaders(input: { - baseHeaders: Record; - providerHeaders?: Record; - explicitSessionId?: string | null; - continuityKey?: string | null; -}): Record { - const authorization = ( - getInputHeader(input.baseHeaders, 'authorization') - || getInputHeader(input.baseHeaders, 'Authorization') - || '' - ); - const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs'; - const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id'); - const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION; - const userAgent = getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT; - const explicitSessionId = asTrimmedString(input.explicitSessionId); - const continuityKey = asTrimmedString(input.continuityKey); - const sessionId = ( - getInputHeader(input.baseHeaders, 'session_id') - || getInputHeader(input.baseHeaders, 'session-id') - || explicitSessionId - || (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null) - || randomUUID() - ); - const conversationId = ( - getInputHeader(input.baseHeaders, 'conversation_id') - || getInputHeader(input.baseHeaders, 'conversation-id') - || explicitSessionId - || (continuityKey ? sessionId : null) - ); - - return { - Authorization: authorization, - 'Content-Type': 'application/json', - ...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}), - Originator: originator, - Version: version, - Session_id: sessionId, - ...(conversationId ? { Conversation_id: conversationId } : {}), - 'User-Agent': userAgent, - Accept: 'text/event-stream', - Connection: 'Keep-Alive', - }; -} - -function buildGeminiCliRuntimeHeaders(input: { - baseHeaders: Record; - providerHeaders?: Record; - modelName: string; - stream: boolean; -}): Record { - const apiClient = ( - getInputHeader(input.providerHeaders, 'x-goog-api-client') - || getInputHeader(input.baseHeaders, 'x-goog-api-client') - ); - const userAgent = buildGeminiCLIUserAgent( - input.modelName, - getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'), - ); - - const headers: Record = { - Authorization: input.baseHeaders.Authorization, - 'Content-Type': 'application/json', - 'User-Agent': userAgent, - }; - if (apiClient) { - headers['X-Goog-Api-Client'] = apiClient; - } - if (input.stream) { - headers.Accept = 'text/event-stream'; - } - return headers; -} - function buildAntigravityRuntimeHeaders(input: { baseHeaders: Record; stream: boolean; @@ -414,47 +252,6 @@ function buildAntigravityRuntimeHeaders(input: { return headers; } -function buildClaudeRuntimeHeaders(input: { - baseHeaders: Record; - claudeHeaders: Record; - anthropicVersion: string; - stream: boolean; - isClaudeOauthUpstream: boolean; - tokenValue: string; - extraBetas?: string[]; -}): Record { - const anthropicBeta = mergeClaudeBetaHeader( - getInputHeader(input.claudeHeaders, 'anthropic-beta'), - input.extraBetas, - ); - const headers: Record = { - ...input.baseHeaders, - ...input.claudeHeaders, - 'anthropic-version': input.anthropicVersion, - ...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}), - 'Anthropic-Dangerous-Direct-Browser-Access': 'true', - 'X-App': 'cli', - 'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0', - 'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0', - 'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0', - 'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node', - 'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js', - 'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64', - 'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows', - 'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600', - 'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT, - Connection: 'keep-alive', - Accept: input.stream ? 'text/event-stream' : 'application/json', - 'Accept-Encoding': 'gzip, deflate, br, zstd', - }; - if (input.isClaudeOauthUpstream) { - headers.Authorization = `Bearer ${input.tokenValue}`; - } else { - headers['x-api-key'] = input.tokenValue; - } - return headers; -} - function ensureStreamAcceptHeader( headers: Record, stream: boolean, diff --git a/src/server/services/accountMutationWorkflow.test.ts b/src/server/services/accountMutationWorkflow.test.ts new file mode 100644 index 0000000..221fbdc --- /dev/null +++ b/src/server/services/accountMutationWorkflow.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const ensureDefaultTokenForAccountMock = vi.fn(); +const syncTokensFromUpstreamMock = vi.fn(); +const refreshBalanceMock = vi.fn(); +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); + +vi.mock('./accountTokenService.js', () => ({ + ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args), + syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args), +})); + +vi.mock('./balanceService.js', () => ({ + refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args), +})); + +vi.mock('./modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +describe('accountMutationWorkflow', () => { + beforeEach(() => { + ensureDefaultTokenForAccountMock.mockReset(); + syncTokensFromUpstreamMock.mockReset(); + refreshBalanceMock.mockReset(); + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + }); + + it('can ensure a preferred token before syncing upstream tokens', async () => { + ensureDefaultTokenForAccountMock.mockResolvedValue(10); + syncTokensFromUpstreamMock.mockResolvedValue({ total: 2, created: 1, updated: 1 }); + refreshBalanceMock.mockResolvedValue({ balance: 1 }); + refreshModelsForAccountMock.mockResolvedValue({ accountId: 1, refreshed: true }); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 1 }); + + const { convergeAccountMutation } = await import('./accountMutationWorkflow.js'); + const upstreamTokens = [{ name: 'default', key: 'sk-upstream', enabled: true }]; + const result = await convergeAccountMutation({ + accountId: 1, + preferredApiToken: 'sk-preferred', + defaultTokenSource: 'manual', + ensurePreferredTokenBeforeSync: true, + upstreamTokens, + refreshBalance: true, + refreshModels: true, + rebuildRoutes: true, + }); + + expect(ensureDefaultTokenForAccountMock).toHaveBeenCalledWith(1, 'sk-preferred', { + name: 'default', + source: 'manual', + }); + expect(syncTokensFromUpstreamMock).toHaveBeenCalledWith(1, upstreamTokens); + expect(refreshBalanceMock).toHaveBeenCalledWith(1); + expect(refreshModelsForAccountMock).toHaveBeenCalledWith(1); + expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1); + expect(ensureDefaultTokenForAccountMock.mock.invocationCallOrder[0]).toBeLessThan( + syncTokensFromUpstreamMock.mock.invocationCallOrder[0]!, + ); + expect(result.defaultTokenId).toBe(10); + expect(result.tokenSync).toEqual({ total: 2, created: 1, updated: 1 }); + expect(result.refreshedBalance).toBe(true); + expect(result.refreshedModels).toBe(true); + expect(result.rebuiltRoutes).toBe(true); + }); + + it('falls back to ensuring the preferred token when upstream tokens are absent', async () => { + ensureDefaultTokenForAccountMock.mockResolvedValue(22); + + const { convergeAccountMutation } = await import('./accountMutationWorkflow.js'); + const result = await convergeAccountMutation({ + accountId: 2, + preferredApiToken: 'sk-fallback', + defaultTokenSource: 'sync', + }); + + expect(syncTokensFromUpstreamMock).not.toHaveBeenCalled(); + expect(ensureDefaultTokenForAccountMock).toHaveBeenCalledWith(2, 'sk-fallback', { + name: 'default', + source: 'sync', + }); + expect(result.defaultTokenId).toBe(22); + expect(result.tokenSync).toBeNull(); + }); + + it('continues through later refresh steps when continueOnError is enabled', async () => { + refreshBalanceMock.mockRejectedValue(new Error('balance failed')); + refreshModelsForAccountMock.mockResolvedValue({ accountId: 3, refreshed: true }); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 0 }); + + const { convergeAccountMutation } = await import('./accountMutationWorkflow.js'); + const result = await convergeAccountMutation({ + accountId: 3, + refreshBalance: true, + refreshModels: true, + rebuildRoutes: true, + continueOnError: true, + }); + + expect(result.refreshedBalance).toBe(false); + expect(result.refreshedModels).toBe(true); + expect(result.rebuiltRoutes).toBe(true); + expect(refreshModelsForAccountMock).toHaveBeenCalledWith(3); + expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1); + }); + + it('refreshes model coverage in batches and maps failures', async () => { + refreshModelsForAccountMock + .mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' }) + .mockRejectedValueOnce(new Error('coverage failed')); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue({ createdRoutes: 2 }); + + const { refreshAccountCoverageBatch } = await import('./accountMutationWorkflow.js'); + const result = await refreshAccountCoverageBatch({ + accountIds: [1, 2], + batchSize: 1, + mapFailure: (accountId, errorMessage) => ({ accountId, errorMessage }), + }); + + expect(result.refresh).toEqual([ + { accountId: 1, refreshed: true, status: 'success' }, + { accountId: 2, errorMessage: 'coverage failed' }, + ]); + expect(result.rebuild).toEqual({ + success: true, + result: { createdRoutes: 2 }, + }); + }); +}); diff --git a/src/server/services/accountMutationWorkflow.ts b/src/server/services/accountMutationWorkflow.ts new file mode 100644 index 0000000..cc4b654 --- /dev/null +++ b/src/server/services/accountMutationWorkflow.ts @@ -0,0 +1,172 @@ +import { refreshBalance } from './balanceService.js'; +import { + ensureDefaultTokenForAccount, + syncTokensFromUpstream, +} from './accountTokenService.js'; +import { + rebuildTokenRoutesFromAvailability, + refreshModelsForAccount, + type ModelRefreshResult, +} from './modelService.js'; + +type UpstreamTokenLike = { + name?: string | null; + key?: string | null; + enabled?: boolean | null; + tokenGroup?: string | null; +}; + +export type CoverageBatchRebuildResult = + | { success: true; result: Awaited> } + | { success: false; error: string }; + +export async function convergeAccountMutation(input: { + accountId: number; + preferredApiToken?: string | null; + defaultTokenSource?: string; + ensurePreferredTokenBeforeSync?: boolean; + upstreamTokens?: UpstreamTokenLike[]; + refreshBalance?: boolean; + refreshModels?: boolean; + rebuildRoutes?: boolean; + continueOnError?: boolean; +}): Promise<{ + defaultTokenId: number | null; + tokenSync: Awaited> | null; + refreshedBalance: boolean; + refreshedModels: boolean; + rebuiltRoutes: boolean; + balanceResult: Awaited> | null; + modelRefreshResult: ModelRefreshResult | null; + rebuildResult: Awaited> | null; +}> { + const result = { + defaultTokenId: null as number | null, + tokenSync: null as Awaited> | null, + refreshedBalance: false, + refreshedModels: false, + rebuiltRoutes: false, + balanceResult: null as Awaited> | null, + modelRefreshResult: null as ModelRefreshResult | null, + rebuildResult: null as Awaited> | null, + }; + + const runStep = async (fn: () => Promise): Promise => { + if (!input.continueOnError) return fn(); + try { + return await fn(); + } catch { + return null; + } + }; + + if (input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) { + const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount( + input.accountId, + input.preferredApiToken!, + { name: 'default', source: input.defaultTokenSource || 'manual' }, + )); + if (defaultTokenId != null) { + result.defaultTokenId = defaultTokenId; + } + } + + if ((input.upstreamTokens?.length || 0) > 0) { + const tokenSync = await runStep(() => syncTokensFromUpstream(input.accountId, input.upstreamTokens!)); + if (tokenSync) { + result.tokenSync = tokenSync; + result.defaultTokenId = tokenSync.defaultTokenId ?? result.defaultTokenId; + } + } else if (!input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) { + const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount( + input.accountId, + input.preferredApiToken!, + { name: 'default', source: input.defaultTokenSource || 'manual' }, + )); + if (defaultTokenId != null) { + result.defaultTokenId = defaultTokenId; + } + } + + if (input.refreshBalance) { + const balanceResult = await runStep(() => refreshBalance(input.accountId)); + if (balanceResult) { + result.balanceResult = balanceResult; + result.refreshedBalance = true; + } + } + + if (input.refreshModels) { + const modelRefreshResult = await runStep(() => refreshModelsForAccount(input.accountId)); + if (modelRefreshResult) { + result.modelRefreshResult = modelRefreshResult; + result.refreshedModels = true; + } + } + + if (input.rebuildRoutes) { + const rebuildResult = await runStep(() => rebuildTokenRoutesFromAvailability()); + if (rebuildResult) { + result.rebuildResult = rebuildResult; + result.rebuiltRoutes = true; + } + } + + return result; +} + +export async function refreshAccountCoverageBatch(input: { + accountIds: number[]; + batchSize: number; + mapFailure: (accountId: number, errorMessage: string) => TFailure; +}): Promise<{ + refresh: Array; + rebuild: CoverageBatchRebuildResult | null; +}> { + const uniqueAccountIds = Array.from(new Set( + input.accountIds.filter((id) => Number.isFinite(id) && id > 0), + )); + + if (uniqueAccountIds.length === 0) { + return { refresh: [], rebuild: null }; + } + + const refresh: Array = []; + for (let offset = 0; offset < uniqueAccountIds.length; offset += input.batchSize) { + const batch = uniqueAccountIds.slice(offset, offset + input.batchSize); + const settled = await Promise.allSettled( + batch.map(async (accountId) => refreshModelsForAccount(accountId)), + ); + settled.forEach((entry, index) => { + if (entry.status === 'fulfilled') { + refresh.push(entry.value); + return; + } + + const accountId = batch[index] || 0; + const errorMessage = entry.reason instanceof Error + ? entry.reason.message + : String(entry.reason || 'coverage refresh failed'); + refresh.push(input.mapFailure(accountId, errorMessage)); + }); + } + + try { + return { + refresh, + rebuild: { + success: true, + result: await rebuildTokenRoutesFromAvailability(), + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed'); + return { + refresh, + rebuild: { + success: false, + error: errorMessage, + }, + }; + } +} diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index a2e8e21..4ea984f 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -3,7 +3,7 @@ import cron from 'node-cron'; import { db, schema } from '../db/index.js'; import { upsertSetting } from '../db/upsertSetting.js'; import { mergeAccountExtraConfig } from './accountExtraConfig.js'; -import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; +import { getOauthInfoFromAccount } from './oauth/oauthAccount.js'; const BACKUP_VERSION = '2.1'; @@ -317,7 +317,7 @@ function normalizeLegacyQuota(raw: unknown): number { } function resolveImportedOauthColumns(row: Pick) { - const oauth = getOauthInfoFromExtraConfig(row.extraConfig); + const oauth = getOauthInfoFromAccount(row); const oauthProvider = row.oauthProvider || oauth?.provider || null; const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null; const oauthProjectId = row.oauthProjectId || oauth?.projectId || null; diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index b54cc08..8364678 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -757,9 +757,9 @@ describe('refreshModelsForAccount credential discovery', () => { .get(); const parsed = JSON.parse(latest!.extraConfig || '{}'); expect(parsed.oauth).toMatchObject({ - provider: 'codex', modelDiscoveryStatus: 'abnormal', }); + expect(parsed.oauth).not.toHaveProperty('provider'); expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403'); expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/); expect(parsed.runtimeHealth?.state).toBe('unhealthy'); @@ -990,12 +990,12 @@ describe('refreshModelsForAccount credential discovery', () => { }); const parsed = JSON.parse(latest!.extraConfig || '{}'); expect(parsed.oauth).toMatchObject({ - provider: 'gemini-cli', email: 'gemini-refreshed-user@example.com', refreshToken: 'gemini-refresh-token-next', - projectId: 'project-refresh-demo', modelDiscoveryStatus: 'healthy', }); + expect(parsed.oauth).not.toHaveProperty('provider'); + expect(parsed.oauth).not.toHaveProperty('projectId'); }); it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => { diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 7ab8a30..9c2a18a 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -21,8 +21,8 @@ import { invalidateTokenRouterCache } from './tokenRouter.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; -import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js'; -import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; +import { isCodexPlatform } from './oauth/codexAccount.js'; +import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js'; import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js'; import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js'; import { @@ -241,10 +241,10 @@ async function updateOauthModelDiscoveryState(input: { lastModelSyncError?: string; lastDiscoveredModels?: string[]; }) { - const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig); + const oauth = getOauthInfoFromAccount(input.account); if (!oauth) return input.account.extraConfig || null; const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, { - oauth: buildOauthInfo(input.account.extraConfig, { + oauth: buildStoredOauthStateFromAccount(input.account, { provider: oauth.provider, modelDiscoveryStatus: input.status, lastModelSyncAt: input.checkedAt, @@ -267,7 +267,7 @@ async function discoverCodexModelsFromCloud(input: { if (!accessToken) { throw new Error('codex oauth access token missing'); } - const oauth = getCodexOauthInfoFromExtraConfig(input.account.extraConfig); + const oauth = getOauthInfoFromAccount(input.account); const headers: Record = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', @@ -321,7 +321,7 @@ async function validateGeminiCliOauthConnection(input: { if (!accessToken) { throw new Error('gemini cli oauth access token missing'); } - const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig); + const oauth = getOauthInfoFromAccount(input.account); const projectId = (oauth?.projectId || '').trim(); if (!projectId) { throw new Error('gemini cli oauth project id missing'); @@ -357,7 +357,7 @@ async function discoverAntigravityModelsFromCloud(input: { throw new Error('antigravity oauth access token missing'); } - const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig); + const oauth = getOauthInfoFromAccount(input.account); const requestBody = oauth?.projectId ? { project: oauth.projectId } : {}; let lastError = ''; @@ -508,7 +508,7 @@ export async function refreshModelsForAccount( const account = row.accounts; const site = row.sites; - const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + const oauth = getOauthInfoFromAccount(account); const adapter = getAdapter(site.platform); const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig); diff --git a/src/server/services/oauth/oauthAccount.test.ts b/src/server/services/oauth/oauthAccount.test.ts new file mode 100644 index 0000000..6ca83f3 --- /dev/null +++ b/src/server/services/oauth/oauthAccount.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + buildOauthInfoFromAccount, + buildStoredOauthStateFromAccount, + getOauthInfoFromAccount, +} from './oauthAccount.js'; + +describe('oauth account identity helpers', () => { + it('prefers structured oauth identity columns over extraConfig metadata', () => { + const oauth = getOauthInfoFromAccount({ + oauthProvider: 'gemini-cli', + oauthAccountKey: 'structured-user@example.com', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountKey: 'json-user', + projectId: 'json-project', + refreshToken: 'refresh-token', + quota: { status: 'supported' }, + }, + }), + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'gemini-cli', + accountId: 'structured-user@example.com', + accountKey: 'structured-user@example.com', + projectId: 'structured-project', + refreshToken: 'refresh-token', + quota: { status: 'supported' }, + })); + }); + + it('falls back to extraConfig metadata when structured columns are absent', () => { + const oauth = getOauthInfoFromAccount({ + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountKey: 'json-user', + projectId: 'json-project', + refreshToken: 'refresh-token', + }, + }), + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'codex', + accountId: 'json-user', + accountKey: 'json-user', + projectId: 'json-project', + refreshToken: 'refresh-token', + })); + }); + + it('reconstructs oauth runtime state from extraConfig even when identity fields are stripped', () => { + const oauth = getOauthInfoFromAccount({ + oauthProvider: 'gemini-cli', + oauthAccountKey: 'structured-user@example.com', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + email: 'structured-user@example.com', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + }, + }), + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'gemini-cli', + accountId: 'structured-user@example.com', + accountKey: 'structured-user@example.com', + projectId: 'structured-project', + email: 'structured-user@example.com', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + })); + }); + + it('builds patched oauth state from structured identity columns and current runtime state', () => { + const oauth = buildOauthInfoFromAccount({ + oauthProvider: 'codex', + oauthAccountKey: 'structured-account', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + }, + }), + }, { + quota: { status: 'supported' } as any, + }); + + expect(oauth).toEqual(expect.objectContaining({ + provider: 'codex', + accountId: 'structured-account', + accountKey: 'structured-account', + projectId: 'structured-project', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + quota: { status: 'supported' }, + })); + }); + + it('strips oauth identity fields when preparing persisted extraConfig state', () => { + const oauth = buildStoredOauthStateFromAccount({ + oauthProvider: 'codex', + oauthAccountKey: 'structured-account', + oauthProjectId: 'structured-project', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountId: 'json-account', + accountKey: 'json-account', + projectId: 'json-project', + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + }, + }), + }, { + quota: { status: 'supported' } as any, + }); + + expect(oauth).toEqual({ + refreshToken: 'refresh-token', + modelDiscoveryStatus: 'healthy', + quota: { status: 'supported' }, + }); + }); +}); diff --git a/src/server/services/oauth/oauthAccount.ts b/src/server/services/oauth/oauthAccount.ts index 4a99753..79f3a86 100644 --- a/src/server/services/oauth/oauthAccount.ts +++ b/src/server/services/oauth/oauthAccount.ts @@ -43,6 +43,15 @@ export type OauthInfo = { lastDiscoveredModels?: string[]; }; +export type StoredOauthState = Omit; + +type OauthIdentityCarrier = { + extraConfig?: string | null; + oauthProvider?: string | null; + oauthAccountKey?: string | null; + oauthProjectId?: string | null; +}; + function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig { if (!extraConfig) return {}; try { @@ -104,14 +113,12 @@ function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | und return undefined; } -export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null { +function parseStoredOauthState(extraConfig?: string | null): Partial | null { const parsed = parseExtraConfig(extraConfig).oauth; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - const provider = asTrimmedString(parsed.provider); - if (!provider) return null; const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId); return { - provider, + provider: asTrimmedString(parsed.provider), accountId: asTrimmedString(parsed.accountId) || accountKey, accountKey, email: asTrimmedString(parsed.email), @@ -129,6 +136,46 @@ export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthI }; } +export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null { + const parsed = parseStoredOauthState(extraConfig); + const provider = parsed?.provider; + if (!provider) return null; + return { + provider, + accountId: parsed.accountId || parsed.accountKey, + accountKey: parsed.accountKey, + email: parsed.email, + planType: parsed.planType, + projectId: parsed.projectId, + tokenExpiresAt: parsed.tokenExpiresAt, + refreshToken: parsed.refreshToken, + idToken: parsed.idToken, + providerData: parsed.providerData, + quota: parsed.quota, + modelDiscoveryStatus: parsed.modelDiscoveryStatus, + lastModelSyncAt: parsed.lastModelSyncAt, + lastModelSyncError: parsed.lastModelSyncError, + lastDiscoveredModels: parsed.lastDiscoveredModels, + }; +} + +export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null): OauthInfo | null { + if (!account) return null; + const stored = parseStoredOauthState(account.extraConfig); + const provider = asTrimmedString(account.oauthProvider) || stored?.provider; + if (!provider) return null; + const structuredAccountKey = asTrimmedString(account.oauthAccountKey); + const accountKey = structuredAccountKey || stored?.accountKey || stored?.accountId; + const projectId = asTrimmedString(account.oauthProjectId) || stored?.projectId; + return { + ...(stored || {}), + provider, + accountId: structuredAccountKey || stored?.accountId || accountKey, + accountKey, + projectId, + }; +} + export function buildOauthInfo( extraConfig?: string | null, patch: Partial = {}, @@ -152,6 +199,47 @@ export function buildOauthInfo( return next; } +export function buildOauthInfoFromAccount( + account?: OauthIdentityCarrier | null, + patch: Partial = {}, +): OauthInfo { + const provider = patch.provider || getOauthInfoFromAccount(account)?.provider; + if (!provider) { + throw new Error('oauth provider is required'); + } + const current = getOauthInfoFromAccount(account); + const next: OauthInfo = { + provider, + ...(current || {}), + ...patch, + }; + if (!next.accountKey && next.accountId) { + next.accountKey = next.accountId; + } + if (!next.accountId && next.accountKey) { + next.accountId = next.accountKey; + } + return next; +} + +export function buildStoredOauthState(oauth: OauthInfo): StoredOauthState { + const { + provider: _provider, + accountId: _accountId, + accountKey: _accountKey, + projectId: _projectId, + ...stored + } = oauth; + return stored; +} + +export function buildStoredOauthStateFromAccount( + account?: OauthIdentityCarrier | null, + patch: Partial = {}, +): StoredOauthState { + return buildStoredOauthState(buildOauthInfoFromAccount(account, patch)); +} + export function isOauthProvider( account: Pick | string | null | undefined, provider?: string, diff --git a/src/server/services/oauth/quota.ts b/src/server/services/oauth/quota.ts index 1406c92..3a79dcb 100644 --- a/src/server/services/oauth/quota.ts +++ b/src/server/services/oauth/quota.ts @@ -1,7 +1,11 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import { mergeAccountExtraConfig } from '../accountExtraConfig.js'; -import { buildOauthInfo, getOauthInfoFromExtraConfig, type OauthInfo } from './oauthAccount.js'; +import { + buildStoredOauthStateFromAccount, + getOauthInfoFromAccount, + type OauthInfo, +} from './oauthAccount.js'; import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js'; type CodexJwtClaims = { @@ -192,12 +196,12 @@ async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnaps if (!account) { throw new Error('oauth account not found'); } - const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + const oauth = getOauthInfoFromAccount(account); if (!oauth) { throw new Error('account is not managed by oauth'); } const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, { - oauth: buildOauthInfo(account.extraConfig, { + oauth: buildStoredOauthStateFromAccount(account, { quota: snapshot, }), }); @@ -213,7 +217,7 @@ export async function refreshOauthQuotaSnapshot(accountId: number): Promise { - const oauth = getOauthInfoFromExtraConfig(row.accounts.extraConfig)!; + const oauth = getOauthInfoFromAccount(row.accounts)!; const models = modelMap.get(row.accounts.id) || []; const status = ( oauth.modelDiscoveryStatus === 'abnormal' @@ -553,8 +559,8 @@ export async function deleteOauthConnection(accountId: number) { if (!account) { throw new Error('oauth account not found'); } - const oauth = getOauthInfoFromExtraConfig(account.extraConfig); - if (!oauth) { + const normalizedOauth = getOauthInfoFromAccount(account); + if (!normalizedOauth) { throw new Error('account is not managed by oauth'); } await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run(); @@ -574,7 +580,7 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st if (!account) { throw new Error('oauth account not found'); } - const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + const oauth = getOauthInfoFromAccount(account); if (!oauth) { throw new Error('account is not managed by oauth'); } @@ -587,10 +593,18 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st } export function buildOauthProviderHeaders(input: { + account?: { + extraConfig?: string | null; + oauthProvider?: string | null; + oauthAccountKey?: string | null; + oauthProjectId?: string | null; + } | null; extraConfig?: string | null; downstreamHeaders?: Record; }) { - const oauth = getOauthInfoFromExtraConfig(input.extraConfig); + const oauth = getOauthInfoFromAccount(input.account || { + extraConfig: input.extraConfig, + }); if (!oauth) return {}; const definition = getOAuthProviderDefinition(oauth.provider); if (!definition?.buildProxyHeaders) return {}; @@ -619,7 +633,7 @@ export async function refreshOauthAccessToken(accountId: number) { if (!account) { throw new Error('oauth account not found'); } - const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + const oauth = getOauthInfoFromAccount(account); if (!oauth?.refreshToken) { throw new Error('oauth refresh token missing'); } @@ -635,7 +649,7 @@ export async function refreshOauthAccessToken(accountId: number) { providerData: oauth.providerData, }, }); - const nextOauth = buildOauthInfo(account.extraConfig, { + const nextOauth = buildOauthInfoFromAccount(account, { provider: oauth.provider, accountId: refreshed.accountId || oauth.accountId, accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId, @@ -652,7 +666,7 @@ export async function refreshOauthAccessToken(accountId: number) { }); const extraConfig = mergeAccountExtraConfig(account.extraConfig, { credentialMode: 'session', - oauth: nextOauth, + oauth: buildStoredOauthStateFromAccount(account, nextOauth), }); await db.update(schema.accounts).set({ diff --git a/src/server/services/platforms/claude.ts b/src/server/services/platforms/claude.ts index c2e6ffb..28a7e79 100644 --- a/src/server/services/platforms/claude.ts +++ b/src/server/services/platforms/claude.ts @@ -1,20 +1,8 @@ -import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { StandardApiProviderAdapterBase } from './standardApiProvider.js'; const DEFAULT_ANTHROPIC_VERSION = '2023-06-01'; -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function resolveClaudeModelsUrl(baseUrl: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (/\/v\d+(\.\d+)?$/i.test(normalized)) { - return `${normalized}/models`; - } - return `${normalized}/v1/models`; -} - -export class ClaudeAdapter extends BasePlatformAdapter { +export class ClaudeAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'claude'; async detect(url: string): Promise { @@ -22,34 +10,13 @@ export class ClaudeAdapter extends BasePlatformAdapter { return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1'); } - override async login(_baseUrl: string, _username: string, _password: string) { - return { success: false, message: 'login endpoint not supported' }; - } - - override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { - return null; - } - - async checkin(_baseUrl: string, _accessToken: string): Promise { - return { success: false, message: 'checkin endpoint not supported' }; - } - - async getBalance(_baseUrl: string, _accessToken: string): Promise { - // Official Anthropic API keys generally do not expose account balance via API. - return { balance: 0, used: 0, quota: 0 }; - } - async getModels(baseUrl: string, apiToken: string): Promise { - try { - const res = await this.fetchJson(resolveClaudeModelsUrl(baseUrl), { - headers: { - 'x-api-key': apiToken, - 'anthropic-version': DEFAULT_ANTHROPIC_VERSION, - }, - }); - return (res?.data || []).map((m: any) => m?.id).filter(Boolean); - } catch { - return []; - } + return this.fetchModelsFromStandardEndpoint({ + baseUrl, + headers: { + 'x-api-key': apiToken, + 'anthropic-version': DEFAULT_ANTHROPIC_VERSION, + }, + }); } } diff --git a/src/server/services/platforms/cliproxyapi.ts b/src/server/services/platforms/cliproxyapi.ts index d175704..dca644b 100644 --- a/src/server/services/platforms/cliproxyapi.ts +++ b/src/server/services/platforms/cliproxyapi.ts @@ -1,30 +1,27 @@ -import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { + StandardApiProviderAdapterBase, + normalizePlatformBaseUrl, + resolveVersionedModelsUrl, +} from './standardApiProvider.js'; -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -export class CliProxyApiAdapter extends BasePlatformAdapter { +export class CliProxyApiAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'cliproxyapi'; + protected override loginUnsupportedMessage = 'CLIProxyAPI does not support login'; + protected override checkinUnsupportedMessage = 'CLIProxyAPI does not support checkin'; async detect(url: string): Promise { const normalized = (url || '').toLowerCase(); - // Quick check: default CLIProxyAPI port if (/:8317(\/|$)/.test(normalized)) { return true; } - // Quick check: common hostname keyword if (normalized.includes('cliproxy')) { return true; } - // Probe management endpoint with strict signature checks. - // Do not trust bare 401/403 because many non-CLIProxy sites may return - // those statuses for unknown/protected paths. try { - const base = normalizeBaseUrl(url); + const base = normalizePlatformBaseUrl(url); const { fetch } = await import('undici'); const res = await fetch(`${base}/v0/management/openai-compatibility`, { method: 'GET', @@ -53,31 +50,11 @@ export class CliProxyApiAdapter extends BasePlatformAdapter { } } - override async login(_baseUrl: string, _username: string, _password: string) { - return { success: false as const, message: 'CLIProxyAPI does not support login' }; - } - - override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { - return null; - } - - async checkin(_baseUrl: string, _accessToken: string): Promise { - return { success: false, message: 'CLIProxyAPI does not support checkin' }; - } - - async getBalance(_baseUrl: string, _accessToken: string): Promise { - return { balance: 0, used: 0, quota: 0 }; - } - async getModels(baseUrl: string, apiToken: string): Promise { - try { - const base = normalizeBaseUrl(baseUrl); - const res = await this.fetchJson(`${base}/v1/models`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - return (res?.data || []).map((m: any) => m?.id).filter(Boolean); - } catch { - return []; - } + return this.fetchModelsFromStandardEndpoint({ + baseUrl, + headers: { Authorization: `Bearer ${apiToken}` }, + resolveUrl: resolveVersionedModelsUrl, + }); } } diff --git a/src/server/services/platforms/gemini.ts b/src/server/services/platforms/gemini.ts index f1315e9..381b35b 100644 --- a/src/server/services/platforms/gemini.ts +++ b/src/server/services/platforms/gemini.ts @@ -1,8 +1,4 @@ -import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; - -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} +import { StandardApiProviderAdapterBase, normalizePlatformBaseUrl } from './standardApiProvider.js'; function stripModelPrefix(name: string): string { const trimmed = name.trim(); @@ -24,13 +20,13 @@ function isOpenAiCompatGeminiBase(baseUrl: string): boolean { } function resolveGeminiOpenAiModelsUrl(baseUrl: string): string { - const normalized = normalizeBaseUrl(baseUrl); + const normalized = normalizePlatformBaseUrl(baseUrl); if (/\/models$/i.test(normalized)) return normalized; return `${normalized}/models`; } function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string { - const normalized = normalizeBaseUrl(baseUrl); + const normalized = normalizePlatformBaseUrl(baseUrl); const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized) ? normalized : `${normalized}/v1beta`; @@ -41,7 +37,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`; } -export class GeminiAdapter extends BasePlatformAdapter { +export class GeminiAdapter extends StandardApiProviderAdapterBase { readonly platformName: string = 'gemini'; async detect(url: string): Promise { @@ -53,34 +49,16 @@ export class GeminiAdapter extends BasePlatformAdapter { ); } - override async login(_baseUrl: string, _username: string, _password: string) { - return { success: false, message: 'login endpoint not supported' }; - } - - override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { - return null; - } - - async checkin(_baseUrl: string, _accessToken: string): Promise { - return { success: false, message: 'checkin endpoint not supported' }; - } - - async getBalance(_baseUrl: string, _accessToken: string): Promise { - // Gemini API keys generally do not expose account balance via API. - return { balance: 0, used: 0, quota: 0 }; - } - async getModels(baseUrl: string, apiToken: string): Promise { - const normalizedBase = normalizeBaseUrl(baseUrl); + const normalizedBase = normalizePlatformBaseUrl(baseUrl); if (isOpenAiCompatGeminiBase(normalizedBase)) { - try { - const res = await this.fetchJson(resolveGeminiOpenAiModelsUrl(normalizedBase), { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean); - if (openAiModels.length > 0) return normalizeModelList(openAiModels); - } catch {} + const openAiModels = await this.fetchModelsFromStandardEndpoint({ + baseUrl: normalizedBase, + headers: { Authorization: `Bearer ${apiToken}` }, + resolveUrl: resolveGeminiOpenAiModelsUrl, + }); + if (openAiModels.length > 0) return normalizeModelList(openAiModels); } try { @@ -92,13 +70,11 @@ export class GeminiAdapter extends BasePlatformAdapter { } catch {} if (!isOpenAiCompatGeminiBase(normalizedBase)) { - try { - const res = await this.fetchJson(`${normalizedBase}/v1beta/openai/models`, { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean); - if (openAiModels.length > 0) return normalizeModelList(openAiModels); - } catch {} + const openAiModels = await this.fetchModelsFromStandardEndpoint({ + baseUrl: `${normalizedBase}/v1beta/openai`, + headers: { Authorization: `Bearer ${apiToken}` }, + }); + if (openAiModels.length > 0) return normalizeModelList(openAiModels); } return []; diff --git a/src/server/services/platforms/llmUpstream.test.ts b/src/server/services/platforms/llmUpstream.test.ts index ee4e176..c98f215 100644 --- a/src/server/services/platforms/llmUpstream.test.ts +++ b/src/server/services/platforms/llmUpstream.test.ts @@ -4,6 +4,7 @@ import { AddressInfo } from 'node:net'; import { OpenAiAdapter } from './openai.js'; import { ClaudeAdapter } from './claude.js'; import { GeminiAdapter } from './gemini.js'; +import { CliProxyApiAdapter } from './cliproxyapi.js'; interface RequestSnapshot { method: string; @@ -52,6 +53,20 @@ describe('official llm upstream adapters', () => { return; } + if (req.url === '/cliproxy/v1/models') { + if (req.headers.authorization !== 'Bearer sk-cpa') { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: { message: 'unauthorized' } })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + object: 'list', + data: [{ id: 'gpt-5.4' }, { id: 'gpt-5.2-codex' }], + })); + return; + } + if (req.url?.startsWith('/gemini/v1beta/models')) { const url = new URL(req.url, 'http://127.0.0.1'); if (url.searchParams.get('key') !== 'gemini-key') { @@ -112,6 +127,12 @@ describe('official llm upstream adapters', () => { expect(models).toEqual(['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001']); }); + it('fetches models from cliproxy openai-compatible upstream', async () => { + const adapter = new CliProxyApiAdapter(); + const models = await adapter.getModels(`${baseUrl}/cliproxy`, 'sk-cpa'); + expect(models).toEqual(['gpt-5.4', 'gpt-5.2-codex']); + }); + it('fetches models from gemini native endpoint and normalizes model names', async () => { const adapter = new GeminiAdapter(); const models = await adapter.getModels(`${baseUrl}/gemini/v1beta`, 'gemini-key'); diff --git a/src/server/services/platforms/openai.ts b/src/server/services/platforms/openai.ts index e391d19..8af3a0c 100644 --- a/src/server/services/platforms/openai.ts +++ b/src/server/services/platforms/openai.ts @@ -1,18 +1,6 @@ -import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { StandardApiProviderAdapterBase } from './standardApiProvider.js'; -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function resolveOpenAiModelsUrl(baseUrl: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (/\/v\d+(\.\d+)?$/i.test(normalized)) { - return `${normalized}/models`; - } - return `${normalized}/v1/models`; -} - -export class OpenAiAdapter extends BasePlatformAdapter { +export class OpenAiAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'openai'; async detect(url: string): Promise { @@ -20,31 +8,10 @@ export class OpenAiAdapter extends BasePlatformAdapter { return normalized.includes('api.openai.com'); } - override async login(_baseUrl: string, _username: string, _password: string) { - return { success: false, message: 'login endpoint not supported' }; - } - - override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { - return null; - } - - async checkin(_baseUrl: string, _accessToken: string): Promise { - return { success: false, message: 'checkin endpoint not supported' }; - } - - async getBalance(_baseUrl: string, _accessToken: string): Promise { - // Official OpenAI API keys generally do not expose account balance via API. - return { balance: 0, used: 0, quota: 0 }; - } - async getModels(baseUrl: string, apiToken: string): Promise { - try { - const res = await this.fetchJson(resolveOpenAiModelsUrl(baseUrl), { - headers: { Authorization: `Bearer ${apiToken}` }, - }); - return (res?.data || []).map((m: any) => m?.id).filter(Boolean); - } catch { - return []; - } + return this.fetchModelsFromStandardEndpoint({ + baseUrl, + headers: { Authorization: `Bearer ${apiToken}` }, + }); } } diff --git a/src/server/services/platforms/standardApiProvider.test.ts b/src/server/services/platforms/standardApiProvider.test.ts new file mode 100644 index 0000000..fe2e04b --- /dev/null +++ b/src/server/services/platforms/standardApiProvider.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + StandardApiProviderAdapterBase, + normalizePlatformBaseUrl, + resolveVersionedModelsUrl, +} from './standardApiProvider.js'; + +class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase { + readonly platformName = 'test-standard'; + + async detect(_url: string): Promise { + return false; + } + + async getModels(_baseUrl: string, _token: string): Promise { + return []; + } +} + +describe('standardApiProvider helpers', () => { + it('normalizes provider base urls and appends /v1/models when needed', () => { + expect(normalizePlatformBaseUrl('https://api.example.com///')).toBe('https://api.example.com'); + expect(resolveVersionedModelsUrl('https://api.example.com')).toBe('https://api.example.com/v1/models'); + expect(resolveVersionedModelsUrl('https://api.example.com/v1')).toBe('https://api.example.com/v1/models'); + expect(resolveVersionedModelsUrl('https://api.example.com/v1beta')).toBe('https://api.example.com/v1beta/models'); + }); + + it('provides shared unsupported login/checkin and zero-balance defaults', async () => { + const adapter = new TestStandardApiProviderAdapter(); + + await expect(adapter.login('https://api.example.com', 'user', 'pass')).resolves.toEqual({ + success: false, + message: 'login endpoint not supported', + }); + await expect(adapter.getUserInfo('https://api.example.com', 'token')).resolves.toBe(null); + await expect(adapter.checkin('https://api.example.com', 'token')).resolves.toEqual({ + success: false, + message: 'checkin endpoint not supported', + }); + await expect(adapter.getBalance('https://api.example.com', 'token')).resolves.toEqual({ + balance: 0, + used: 0, + quota: 0, + }); + }); +}); diff --git a/src/server/services/platforms/standardApiProvider.ts b/src/server/services/platforms/standardApiProvider.ts new file mode 100644 index 0000000..59156f9 --- /dev/null +++ b/src/server/services/platforms/standardApiProvider.ts @@ -0,0 +1,73 @@ +import { + BasePlatformAdapter, + type BalanceInfo, + type CheckinResult, + type UserInfo, +} from './base.js'; + +type FetchModelsOptions = { + baseUrl: string; + headers?: Record; + resolveUrl?: (normalizedBaseUrl: string) => string; + mapResponse?: (payload: any) => unknown[]; +}; + +export function normalizePlatformBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +export function resolveVersionedModelsUrl(baseUrl: string): string { + const normalized = normalizePlatformBaseUrl(baseUrl); + if (/\/v\d+(?:\.\d+)?(?:beta)?$/i.test(normalized)) { + return `${normalized}/models`; + } + return `${normalized}/v1/models`; +} + +export abstract class StandardApiProviderAdapterBase extends BasePlatformAdapter { + protected loginUnsupportedMessage = 'login endpoint not supported'; + protected checkinUnsupportedMessage = 'checkin endpoint not supported'; + + override async login(_baseUrl: string, _username: string, _password: string) { + return { + success: false as const, + message: this.loginUnsupportedMessage, + }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + override async checkin(_baseUrl: string, _accessToken: string): Promise { + return { + success: false, + message: this.checkinUnsupportedMessage, + }; + } + + override async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + protected async fetchModelsFromStandardEndpoint(options: FetchModelsOptions): Promise { + const normalizedBaseUrl = normalizePlatformBaseUrl(options.baseUrl); + const url = options.resolveUrl + ? options.resolveUrl(normalizedBaseUrl) + : resolveVersionedModelsUrl(normalizedBaseUrl); + + try { + const payload = await this.fetchJson(url, { + headers: options.headers, + }); + const rows = options.mapResponse + ? options.mapResponse(payload) + : (payload?.data || []).map((item: any) => item?.id); + return rows + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); + } catch { + return []; + } + } +} diff --git a/src/server/services/proxyInputFileResolver.test.ts b/src/server/services/proxyInputFileResolver.test.ts index a9028c7..0ca9833 100644 --- a/src/server/services/proxyInputFileResolver.test.ts +++ b/src/server/services/proxyInputFileResolver.test.ts @@ -76,4 +76,51 @@ describe('proxyInputFileResolver', () => { }, }); }); + + it('exports generic inline local file resolution for route-level callers', async () => { + getProxyFileByPublicIdForOwnerMock.mockResolvedValue({ + publicId: 'file-metapi-abc', + filename: 'notes.md', + mimeType: 'text/markdown', + contentBase64: Buffer.from('# hello').toString('base64'), + }); + + const { inlineLocalInputFileReferences } = await import('./proxyInputFileResolver.js'); + await expect(inlineLocalInputFileReferences( + { + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { + type: 'file', + file: { + file_id: 'file-metapi-abc', + }, + }, + ], + }, + ], + }, + { ownerType: 'managed_key', ownerId: '7' }, + )).resolves.toEqual({ + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { + type: 'file', + file: { + file_data: Buffer.from('# hello').toString('base64'), + filename: 'notes.md', + mime_type: 'text/markdown', + }, + }, + ], + }, + ], + }); + }); }); diff --git a/src/server/services/proxyInputFileResolver.ts b/src/server/services/proxyInputFileResolver.ts index b987fe1..e3d9290 100644 --- a/src/server/services/proxyInputFileResolver.ts +++ b/src/server/services/proxyInputFileResolver.ts @@ -1,8 +1,10 @@ import type { ProxyResourceOwner } from '../middleware/auth.js'; -import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js'; +import { getProxyFileByPublicIdForOwner } from './proxyFileStore.js'; import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js'; +const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-'; + function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -140,7 +142,6 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`); } return { - fileId: fileLike.fileId, filename: fileLike.filename || stored.filename, fileData: stored.contentBase64, mimeType, @@ -239,6 +240,36 @@ async function resolveResponsesMessageContent(content: unknown, owner: ProxyReso })); } +export async function inlineLocalInputFileReferences( + value: unknown, + owner: ProxyResourceOwner, +): Promise { + if (Array.isArray(value)) { + return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner))); + } + + if (!isRecord(value)) return cloneJsonValue(value); + + const fileLike = normalizeInputFileLike(value); + if (fileLike && shouldResolveInlineFileLike(fileLike)) { + const resolved = await resolveInputFileLike(fileLike, owner); + const type = asTrimmedString(value.type).toLowerCase(); + if (type === 'file') { + return toOpenAiResolvedBlock(resolved); + } + if (type === 'input_file') { + return toResponsesResolvedBlock(resolved); + } + } + + const entries = await Promise.all( + Object.entries(value).map(async ([key, entryValue]) => ( + [key, await inlineLocalInputFileReferences(entryValue, owner)] as const + )), + ); + return Object.fromEntries(entries); +} + export async function resolveOpenAiBodyInputFiles( body: Record, owner: ProxyResourceOwner, diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index 59ac42f..eb5155e 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -44,7 +44,7 @@ type ParsedSiteProxyInput = { proxyUrl: string | null; }; -type SiteProxyConfigLike = { +export type SiteProxyConfigLike = { proxyUrl?: string | null; useSystemProxy?: boolean | null; customHeaders?: string | null; diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 5c4e389..5a4c85a 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -1,5 +1,4 @@ import { eq, inArray } from 'drizzle-orm'; -import { minimatch } from 'minimatch'; import { db, schema } from '../db/index.js'; import { upsertSetting } from '../db/upsertSetting.js'; import { config } from '../config.js'; @@ -10,7 +9,19 @@ import { } from './routeRoutingStrategy.js'; import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; import { isUsableAccountToken } from './accountTokenService.js'; -import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; +import { getOauthInfoFromAccount } from './oauth/oauthAccount.js'; +import { + isExactTokenRouteModelPattern, + isTokenRouteRegexPattern, + matchesTokenRouteModelPattern, + parseTokenRouteRegexPattern, +} from '../../shared/tokenRoutePatterns.js'; +import { + normalizeTokenRouteMode, + type RouteDecision, + type RouteDecisionCandidate, + type RouteMode, +} from '../../shared/tokenRouteContract.js'; interface RouteMatch { route: RouteRow; @@ -61,7 +72,6 @@ const FAILURE_BACKOFF_BASE_SEC = 15; const MIN_EFFECTIVE_UNIT_COST = 1e-6; const ROUND_ROBIN_FAILURE_THRESHOLD = 3; const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const; -const MAX_ROUTE_REGEX_BODY_LENGTH = 256; const SITE_RUNTIME_HEALTH_DECAY_HALF_LIFE_MS = 10 * 60 * 1000; const SITE_RUNTIME_MIN_MULTIPLIER = 0.08; const SITE_RUNTIME_LATENCY_BASELINE_MS = 2_500; @@ -674,7 +684,6 @@ function filterSiteRuntimeBrokenCandidatesByModel( }; } -type RouteMode = 'pattern' | 'explicit_group'; type RouteRow = typeof schema.tokenRoutes.$inferSelect & { routeMode: RouteMode; sourceRouteIds: number[]; @@ -848,33 +857,11 @@ export function filterRecentlyFailedCandidates 0 ? healthy : candidates; } -export interface RouteDecisionCandidate { - channelId: number; - accountId: number; - username: string; - siteName: string; - tokenName: string; - priority: number; - weight: number; - eligible: boolean; - recentlyFailed: boolean; - avoidedByRecentFailure: boolean; - probability: number; - reason: string; -} - -export interface RouteDecisionExplanation { - requestedModel: string; - actualModel: string; - matched: boolean; - routeId?: number; - modelPattern?: string; - selectedChannelId?: number; - selectedAccountId?: number; - selectedLabel?: string; - summary: string[]; - candidates: RouteDecisionCandidate[]; -} +export type RouteDecisionExplanation = RouteDecision & { + routeId?: number; + modelPattern?: string; + selectedAccountId?: number; +}; const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY; @@ -904,154 +891,23 @@ type CostSignal = { }; export function isRegexModelPattern(pattern: string): boolean { - return pattern.trim().toLowerCase().startsWith('re:'); -} - -function readRegexQuantifierLength(pattern: string, startIndex: number): number { - const ch = pattern[startIndex]; - if (ch === '*' || ch === '+' || ch === '?') return 1; - if (ch !== '{') return 0; - - let index = startIndex + 1; - let sawDigit = false; - while (index < pattern.length && /\d/.test(pattern[index])) { - sawDigit = true; - index += 1; - } - if (!sawDigit) return 0; - - if (pattern[index] === ',') { - index += 1; - while (index < pattern.length && /\d/.test(pattern[index])) { - index += 1; - } - } - - if (pattern[index] !== '}') return 0; - return index - startIndex + 1; -} - -function isSafeRegexPatternBody(body: string): boolean { - if (!body || body.length > MAX_ROUTE_REGEX_BODY_LENGTH) return false; - if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) return false; - if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(? = []; - let escaped = false; - let inCharClass = false; - - for (let index = 0; index < body.length; index += 1) { - const ch = body[index]; - - if (escaped) { - escaped = false; - continue; - } - - if (ch === '\\') { - escaped = true; - continue; - } - - if (inCharClass) { - if (ch === ']') inCharClass = false; - continue; - } - - if (ch === '[') { - inCharClass = true; - continue; - } - - if (ch === '(') { - if (body[index + 1] === '?') { - if (body[index + 2] !== ':') { - return false; - } - groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); - index += 2; - continue; - } - - groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); - continue; - } - - if (ch === '|') { - if (groupStack.length > 0) { - groupStack[groupStack.length - 1].hasAlternation = true; - } - continue; - } - - if (ch === ')') { - const group = groupStack.pop(); - if (!group) return false; - - const quantifierLength = readRegexQuantifierLength(body, index + 1); - if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) { - return false; - } - - const parent = groupStack[groupStack.length - 1]; - if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) { - parent.hasInnerQuantifier = true; - } - continue; - } - - const quantifierLength = readRegexQuantifierLength(body, index); - if (quantifierLength > 0) { - if (groupStack.length > 0) { - groupStack[groupStack.length - 1].hasInnerQuantifier = true; - } - index += quantifierLength - 1; - } - } - - return !escaped && !inCharClass && groupStack.length === 0; + return isTokenRouteRegexPattern(pattern); } export function parseRegexModelPattern(pattern: string): RegExp | null { - if (!isRegexModelPattern(pattern)) return null; - const body = pattern.trim().slice(3).trim(); - if (!body) return null; - if (!isSafeRegexPatternBody(body)) return null; - try { - return new RegExp(body); - } catch { - return null; - } -} - + return parseTokenRouteRegexPattern(pattern).regex; +} + export function matchesModelPattern(model: string, pattern: string): boolean { - const normalizedPattern = (pattern || '').trim(); - if (!normalizedPattern) return false; - - if (normalizedPattern === model) return true; - - if (isRegexModelPattern(normalizedPattern)) { - const re = parseRegexModelPattern(normalizedPattern); - return !!re && re.test(model); - } - - return minimatch(model, normalizedPattern); + return matchesTokenRouteModelPattern(model, pattern); } function isExactRouteModelPattern(pattern: string): boolean { - const normalizedPattern = (pattern || '').trim(); - if (!normalizedPattern) return false; - if (isRegexModelPattern(normalizedPattern)) return false; - return !/[\*\?\[]/.test(normalizedPattern); + return isExactTokenRouteModelPattern(pattern); } function normalizeRouteMode(routeMode: string | null | undefined): RouteMode { - return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; + return normalizeTokenRouteMode(routeMode); } function isExplicitGroupRoute(route: Pick | Pick): boolean { @@ -2037,7 +1893,7 @@ export class TokenRouter { return token ? token : null; } - if (getOauthInfoFromExtraConfig(candidate.account.extraConfig)) { + if (getOauthInfoFromAccount(candidate.account)) { const accessToken = candidate.account.accessToken?.trim(); if (accessToken) return accessToken; return null; diff --git a/src/server/transformers/anthropic/messages/inbound.ts b/src/server/transformers/anthropic/messages/inbound.ts index ec071bc..c6bb492 100644 --- a/src/server/transformers/anthropic/messages/inbound.ts +++ b/src/server/transformers/anthropic/messages/inbound.ts @@ -54,61 +54,6 @@ function validateSystemPrompts(body: Record): { statusCode: num return undefined; } -function validateAdaptiveEffort(body: Record): { statusCode: number; payload: unknown } | undefined { - const thinking = isRecord(body.thinking) ? body.thinking : null; - const thinkingType = asTrimmedString(thinking?.type).toLowerCase(); - const outputConfig = isRecord(body.output_config) - ? body.output_config - : (isRecord(body.outputConfig) ? body.outputConfig : null); - - if (!outputConfig || !('effort' in outputConfig)) return undefined; - - const effort = asTrimmedString(outputConfig.effort).toLowerCase(); - if (!effort || thinkingType !== 'adaptive') return undefined; - - if (!['low', 'medium', 'high', 'max'].includes(effort)) { - return invalidRequest('output_config.effort must be one of: low, medium, high, max'); - } - - return undefined; -} - -function validateToolChoice(body: Record): { statusCode: number; payload: unknown } | undefined { - const rawToolChoice = body.tool_choice ?? body.toolChoice; - if (rawToolChoice === undefined) return undefined; - - if (typeof rawToolChoice === 'string') { - const type = asTrimmedString(rawToolChoice).toLowerCase(); - if (!type) return undefined; - if (type === 'required' || type === 'auto' || type === 'none' || type === 'any') return undefined; - if (type === 'tool') { - return invalidRequest('tool_choice.name is required when type is tool'); - } - return invalidRequest('tool_choice.type must be one of: auto, none, any, tool'); - } - - if (!isRecord(rawToolChoice)) { - return invalidRequest('tool_choice must be an object or string'); - } - - const type = asTrimmedString(rawToolChoice.type).toLowerCase(); - if (!['auto', 'none', 'any', 'tool'].includes(type)) { - return invalidRequest('tool_choice.type must be one of: auto, none, any, tool'); - } - - if (type !== 'tool') return undefined; - - const name = asTrimmedString( - rawToolChoice.name - ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), - ); - if (!name) { - return invalidRequest('tool_choice.name is required when type is tool'); - } - - return undefined; -} - function sanitizeAnthropicInboundBody( body: Record, ): { sanitizedBody?: Record; error?: { statusCode: number; payload: unknown } } { @@ -118,12 +63,6 @@ function sanitizeAnthropicInboundBody( const systemError = validateSystemPrompts(body); if (systemError) return { error: systemError }; - const adaptiveEffortError = validateAdaptiveEffort(body); - if (adaptiveEffortError) return { error: adaptiveEffortError }; - - const toolChoiceError = validateToolChoice(body); - if (toolChoiceError) return { error: toolChoiceError }; - const validation = validateAnthropicMessagesBody(body, { autoOptimizeCacheControls: false, }); diff --git a/src/server/transformers/openai/responses/compatibility.ts b/src/server/transformers/openai/responses/compatibility.ts index ca6b323..fe9be9a 100644 --- a/src/server/transformers/openai/responses/compatibility.ts +++ b/src/server/transformers/openai/responses/compatibility.ts @@ -1,4 +1,12 @@ -import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js'; +import { + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContent, + normalizeResponsesMessageItem, +} from './normalization.js'; +import { + convertOpenAiBodyToResponsesBody, + sanitizeResponsesBodyForProxy, +} from './conversion.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; @@ -8,170 +16,6 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function toTextBlockType(role: string): 'input_text' | 'output_text' { - return role === 'assistant' ? 'output_text' : 'input_text'; -} - -function normalizeImageUrlValue(value: unknown): string | Record | null { - if (typeof value === 'string' && value.trim().length > 0) return value; - if (!isRecord(value)) return null; - const url = asTrimmedString(value.url); - if (url) return { ...value, url }; - const imageUrl = asTrimmedString(value.image_url); - if (imageUrl) return imageUrl; - return Object.keys(value).length > 0 ? value : null; -} - -function normalizeAudioInputValue(value: unknown): Record | null { - if (!isRecord(value)) return null; - const data = asTrimmedString(value.data); - const format = asTrimmedString(value.format); - if (!data && !format) return Object.keys(value).length > 0 ? value : null; - return { - ...value, - ...(data ? { data } : {}), - ...(format ? { format } : {}), - }; -} - -function normalizeResponsesContentItem( - item: unknown, - role: string, -): Record | null { - if (typeof item === 'string') { - const text = item.trim(); - return text ? { type: toTextBlockType(role), text } : null; - } - - if (!isRecord(item)) return null; - - const type = asTrimmedString(item.type).toLowerCase(); - if (!type) { - const text = asTrimmedString(item.text ?? item.content ?? item.output_text); - return text ? { type: toTextBlockType(role), text } : null; - } - - if (type === 'input_text' || type === 'output_text' || type === 'text') { - const text = asTrimmedString(item.text ?? item.content ?? item.output_text); - if (!text) return null; - return { - ...item, - type: type === 'text' ? toTextBlockType(role) : type, - text, - }; - } - - if (type === 'input_image' || type === 'image_url') { - const imageUrl = normalizeImageUrlValue(item.image_url ?? item.url); - if (!imageUrl) return null; - return { - ...item, - type: 'input_image', - image_url: imageUrl, - }; - } - - if (type === 'input_audio') { - const inputAudio = normalizeAudioInputValue(item.input_audio); - if (!inputAudio) return null; - return { - ...item, - type: 'input_audio', - input_audio: inputAudio, - }; - } - - if (type === 'file' || type === 'input_file') { - const fileBlock = normalizeInputFileBlock(item); - return fileBlock ? toResponsesInputFileBlock(fileBlock) : null; - } - - if (type === 'function_call' || type === 'function_call_output') { - return item; - } - - return item; -} - -export function normalizeResponsesMessageContent(content: unknown, role: string): unknown { - if (Array.isArray(content)) { - const normalized = content - .map((item) => normalizeResponsesContentItem(item, role)) - .filter((item): item is Record => !!item); - return normalized.length > 0 ? normalized : content; - } - - const single = normalizeResponsesContentItem(content, role); - if (single) return [single]; - return content; -} - -function toResponsesInputMessageFromText(text: string): Record { - return { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text }], - }; -} - -export function normalizeResponsesMessageItem(item: Record): Record { - const type = asTrimmedString(item.type).toLowerCase(); - if (type === 'function_call' || type === 'function_call_output') { - return item; - } - - const role = asTrimmedString(item.role).toLowerCase() || 'user'; - const normalizedContent = normalizeResponsesMessageContent(item.content ?? item.text, role); - - if (type === 'message') { - return { - ...item, - role, - content: normalizedContent, - }; - } - - if (asTrimmedString(item.role)) { - return { - type: 'message', - role, - content: normalizedContent, - }; - } - - if (typeof item.content === 'string') { - const text = item.content.trim(); - return text ? toResponsesInputMessageFromText(text) : item; - } - - return item; -} - -export function normalizeResponsesInputForCompatibility(input: unknown): unknown { - if (typeof input === 'string') { - const normalized = input.trim(); - if (!normalized) return input; - return [toResponsesInputMessageFromText(normalized)]; - } - - if (Array.isArray(input)) { - return input.map((item) => { - if (typeof item === 'string') { - const normalized = item.trim(); - return normalized ? toResponsesInputMessageFromText(normalized) : item; - } - if (!isRecord(item)) return item; - return normalizeResponsesMessageItem(item); - }); - } - - if (isRecord(input)) { - return [normalizeResponsesMessageItem(input)]; - } - - return input; -} - export function buildResponsesCompatibilityBodies( body: Record, ): Record[] { @@ -321,53 +165,6 @@ export function shouldDowngradeResponsesChatToMessages( return /messages\s+is\s+required/i.test(upstreamErrorText); } -function extractTextFromResponsesContent(content: unknown): string { - if (typeof content === 'string') return content.trim(); - if (!Array.isArray(content)) return ''; - return content - .map((item) => { - if (!isRecord(item)) return ''; - return asTrimmedString(item.text ?? item.content ?? item.output_text); - }) - .filter((item) => item.length > 0) - .join('\n'); -} - -function safeJsonStringify(value: unknown): string { - try { - return JSON.stringify(value); - } catch { - return ''; - } -} - -function normalizeOpenAiToolArguments(raw: unknown): string { - if (typeof raw === 'string') return raw; - if (raw === undefined || raw === null) return ''; - if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); - if (Array.isArray(raw) || isRecord(raw)) { - return safeJsonStringify(raw); - } - return ''; -} - -function normalizeToolMessageContent(raw: unknown): string { - if (raw === undefined || raw === null) return ''; - if (typeof raw === 'string') return raw; - if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); - if (Array.isArray(raw)) { - const normalized = normalizeResponsesMessageContent(raw, 'user'); - const text = extractTextFromResponsesContent(normalized); - return text || safeJsonStringify(raw); - } - if (isRecord(raw)) { - const normalized = normalizeResponsesMessageContent(raw, 'user'); - const text = extractTextFromResponsesContent(normalized); - return text || safeJsonStringify(raw); - } - return ''; -} - function toFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } @@ -418,50 +215,6 @@ function stripResponsesMetadata( return next; } -function buildCoreResponsesBody( - body: Record, -): Record | null { - const model = typeof body.model === 'string' ? body.model.trim() : ''; - if (!model) return null; - if (body.input === undefined) return null; - - const core: Record = { - model, - input: body.input, - stream: body.stream === true, - }; - - const maxOutputTokens = toFiniteNumber(body.max_output_tokens); - if (maxOutputTokens !== null && maxOutputTokens > 0) { - core.max_output_tokens = Math.trunc(maxOutputTokens); - } - - const temperature = toFiniteNumber(body.temperature); - if (temperature !== null) core.temperature = temperature; - - const topP = toFiniteNumber(body.top_p); - if (topP !== null) core.top_p = topP; - - const instructions = getExplicitResponsesInstructions(body); - if (instructions !== null) core.instructions = instructions; - - const passthroughFields = [ - 'reasoning', - 'safety_identifier', - 'max_tool_calls', - 'prompt_cache_key', - 'prompt_cache_retention', - 'background', - 'top_logprobs', - ] as const; - for (const key of passthroughFields) { - if (body[key] === undefined) continue; - core[key] = cloneJsonValue(body[key]); - } - - return core; -} - function buildStrictResponsesBody( body: Record, ): Record | null { @@ -481,194 +234,10 @@ function buildStrictResponsesBody( }; } -const ALLOWED_RESPONSES_FIELDS = new Set([ - 'model', - 'input', - 'instructions', - 'max_output_tokens', - 'max_completion_tokens', - 'temperature', - 'top_p', - 'truncation', - 'tools', - 'tool_choice', - 'parallel_tool_calls', - 'metadata', - 'reasoning', - 'store', - 'stream', - 'user', - 'previous_response_id', - 'text', - 'audio', - 'include', - 'response_format', - 'service_tier', - 'stop', - 'n', -]); - -export function convertOpenAiBodyToResponsesBody( - openaiBody: Record, - modelName: string, - stream: boolean, -): Record { - const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : []; - const systemContents: string[] = []; - const inputItems: Array> = []; - - for (const item of rawMessages) { - if (!isRecord(item)) continue; - const role = asTrimmedString(item.role).toLowerCase() || 'user'; - - if (role === 'system' || role === 'developer') { - const normalized = normalizeResponsesMessageContent(item.content, 'user'); - const content = extractTextFromResponsesContent(normalized).trim(); - if (content) systemContents.push(content); - continue; - } - - if (role === 'assistant') { - const normalizedAssistantContent = normalizeResponsesMessageContent(item.content, 'assistant'); - if (Array.isArray(normalizedAssistantContent) && normalizedAssistantContent.length > 0) { - inputItems.push({ - type: 'message', - role: 'assistant', - content: normalizedAssistantContent, - }); - } - - const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; - for (let index = 0; index < rawToolCalls.length; index += 1) { - const toolCall = rawToolCalls[index]; - if (!isRecord(toolCall)) continue; - const functionPart = isRecord(toolCall.function) ? toolCall.function : {}; - const callId = asTrimmedString(toolCall.id) || `call_${Date.now()}_${index}`; - const name = ( - asTrimmedString(functionPart.name) - || asTrimmedString(toolCall.name) - || `tool_${index}` - ); - const argumentsValue = normalizeOpenAiToolArguments( - functionPart.arguments ?? toolCall.arguments, - ); - - inputItems.push({ - type: 'function_call', - call_id: callId, - name, - arguments: argumentsValue, - }); - } - continue; - } - - if (role === 'tool') { - const callId = asTrimmedString(item.tool_call_id) || asTrimmedString(item.id); - if (!callId) continue; - - inputItems.push({ - type: 'function_call_output', - call_id: callId, - output: normalizeToolMessageContent(item.content), - }); - continue; - } - - const normalizedUserContent = normalizeResponsesMessageContent(item.content, 'user'); - if (Array.isArray(normalizedUserContent) && normalizedUserContent.length > 0) { - inputItems.push({ - type: 'message', - role: 'user', - content: normalizedUserContent, - }); - } - } - - const maxOutputTokens = ( - toFiniteNumber(openaiBody.max_output_tokens) - ?? toFiniteNumber(openaiBody.max_completion_tokens) - ?? toFiniteNumber(openaiBody.max_tokens) - ?? 4096 - ); - - const body: Record = { - model: modelName, - stream, - max_output_tokens: maxOutputTokens, - input: inputItems, - }; - - if (systemContents.length > 0) { - body.instructions = systemContents.join('\n\n'); - } - - const temperature = toFiniteNumber(openaiBody.temperature); - if (temperature !== null) body.temperature = temperature; - - const topP = toFiniteNumber(openaiBody.top_p); - if (topP !== null) body.top_p = topP; - - if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata; - if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning; - if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls; - if (openaiBody.tools !== undefined) body.tools = openaiBody.tools; - if (openaiBody.tool_choice !== undefined) body.tool_choice = openaiBody.tool_choice; - - return { - ...body, - input: normalizeResponsesInputForCompatibility(body.input), - }; -} - -export function sanitizeResponsesBodyForProxy( - body: Record, - modelName: string, - stream: boolean, -): Record { - let normalized: Record = { - ...body, - model: modelName, - stream, - }; - - if (normalized.input === undefined) { - if (Array.isArray((normalized as Record).messages)) { - normalized = convertOpenAiBodyToResponsesBody(normalized, modelName, stream); - } else { - const prompt = asTrimmedString((normalized as Record).prompt); - if (prompt) { - normalized = { - ...normalized, - input: [toResponsesInputMessageFromText(prompt)], - }; - } - } - } else { - normalized = { - ...normalized, - input: normalizeResponsesInputForCompatibility(normalized.input), - }; - } - - const sanitized: Record = {}; - for (const [key, value] of Object.entries(normalized)) { - if (!ALLOWED_RESPONSES_FIELDS.has(key)) continue; - if (key === 'max_completion_tokens') continue; - sanitized[key] = value; - } - - const maxOutputTokens = toFiniteNumber(normalized.max_output_tokens); - if (maxOutputTokens !== null && maxOutputTokens > 0) { - sanitized.max_output_tokens = Math.trunc(maxOutputTokens); - } else { - const maxCompletionTokens = toFiniteNumber(normalized.max_completion_tokens); - if (maxCompletionTokens !== null && maxCompletionTokens > 0) { - sanitized.max_output_tokens = Math.trunc(maxCompletionTokens); - } - } - - sanitized.model = modelName; - sanitized.stream = stream; - return sanitized; -} +export { + convertOpenAiBodyToResponsesBody, + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContent, + normalizeResponsesMessageItem, + sanitizeResponsesBodyForProxy, +}; diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index 45d24e1..e06bc31 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -5,7 +5,36 @@ import { convertResponsesBodyToOpenAiBody, sanitizeResponsesBodyForProxy, } from './conversion.js'; -import { buildResponsesCompatibilityBodies } from './compatibility.js'; +import { + buildResponsesCompatibilityBodies, + convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaCompatibility, + normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility, + normalizeResponsesMessageContent as normalizeResponsesMessageContentViaCompatibility, + sanitizeResponsesBodyForProxy as sanitizeResponsesBodyForProxyViaCompatibility, +} from './compatibility.js'; +import { + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContent, +} from './conversion.js'; + +describe('responses conversion single source of truth', () => { + it('exports shared conversion helpers from one implementation', () => { + expect(normalizeResponsesInputForCompatibilityViaCompatibility).toBe(normalizeResponsesInputForCompatibility); + expect( + normalizeResponsesMessageContentViaCompatibility( + [{ type: 'text', text: 'hello' }], + 'user', + ), + ).toEqual( + normalizeResponsesMessageContent( + 'user', + [{ type: 'text', text: 'hello' }], + ), + ); + expect(convertOpenAiBodyToResponsesBodyViaCompatibility).toBe(convertOpenAiBodyToResponsesBody); + expect(sanitizeResponsesBodyForProxyViaCompatibility).toBe(sanitizeResponsesBodyForProxy); + }); +}); describe('sanitizeResponsesBodyForProxy', () => { it('preserves newer Responses request fields needed by the proxy', () => { diff --git a/src/server/transformers/openai/responses/conversion.ts b/src/server/transformers/openai/responses/conversion.ts index 1a83b21..90ba91c 100644 --- a/src/server/transformers/openai/responses/conversion.ts +++ b/src/server/transformers/openai/responses/conversion.ts @@ -1,7 +1,8 @@ import { - normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility, + normalizeResponsesInputForCompatibility, + normalizeResponsesMessageContentBlocks, normalizeResponsesMessageItem, -} from './compatibility.js'; +} from './normalization.js'; import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js'; import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js'; @@ -236,10 +237,6 @@ function normalizeToolOutput(raw: unknown): string { return ''; } -export function normalizeResponsesInputForCompatibility(input: unknown): unknown { - return normalizeResponsesInputForCompatibilityViaCompatibility(input); -} - function toResponsesInputMessageFromText(text: string): Record { return { type: 'message', @@ -366,17 +363,7 @@ function normalizeResponsesBodyForCompatibility( } export function normalizeResponsesMessageContent(role: string, content: unknown): Array> { - const normalized = normalizeResponsesMessageItem({ - type: 'message', - role, - content, - }); - - if (isRecord(normalized) && Array.isArray(normalized.content)) { - return normalized.content.filter((item): item is Record => isRecord(item)); - } - - return []; + return normalizeResponsesMessageContentBlocks(role, content); } const RESPONSES_COMPATIBILITY_FILTER_FIELDS = new Set([ @@ -883,3 +870,5 @@ export function convertResponsesBodyToOpenAiBody( return payload; } + +export { normalizeResponsesInputForCompatibility }; diff --git a/src/server/transformers/openai/responses/normalization.ts b/src/server/transformers/openai/responses/normalization.ts new file mode 100644 index 0000000..bbbb667 --- /dev/null +++ b/src/server/transformers/openai/responses/normalization.ts @@ -0,0 +1,193 @@ +import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function toTextBlockType(role: string): 'input_text' | 'output_text' { + return role === 'assistant' ? 'output_text' : 'input_text'; +} + +function normalizeImageUrlValue(value: unknown): string | Record | null { + if (typeof value === 'string' && value.trim().length > 0) return value; + if (!isRecord(value)) return null; + const url = asTrimmedString(value.url); + if (url) return { ...value, url }; + const imageUrl = asTrimmedString(value.image_url); + if (imageUrl) return imageUrl; + return Object.keys(value).length > 0 ? value : null; +} + +function normalizeAudioInputValue(value: unknown): Record | null { + if (!isRecord(value)) return null; + const data = asTrimmedString(value.data); + const format = asTrimmedString(value.format); + if (!data && !format) return Object.keys(value).length > 0 ? value : null; + return { + ...value, + ...(data ? { data } : {}), + ...(format ? { format } : {}), + }; +} + +function normalizeResponsesContentItem( + item: unknown, + role: string, +): Record | null { + if (typeof item === 'string') { + const text = item.trim(); + return text ? { type: toTextBlockType(role), text } : null; + } + + if (!isRecord(item)) return null; + + const type = asTrimmedString(item.type).toLowerCase(); + if (!type) { + const text = asTrimmedString(item.text ?? item.content ?? item.output_text); + return text ? { type: toTextBlockType(role), text } : null; + } + + if (type === 'input_text' || type === 'output_text' || type === 'text') { + const text = asTrimmedString(item.text ?? item.content ?? item.output_text); + if (!text) return null; + return { + ...item, + type: type === 'text' ? toTextBlockType(role) : type, + text, + }; + } + + if (type === 'input_image' || type === 'image_url') { + const imageUrl = normalizeImageUrlValue(item.image_url ?? item.url); + if (!imageUrl) return null; + return { + ...item, + type: 'input_image', + image_url: imageUrl, + }; + } + + if (type === 'input_audio') { + const inputAudio = normalizeAudioInputValue(item.input_audio); + if (!inputAudio) return null; + return { + ...item, + type: 'input_audio', + input_audio: inputAudio, + }; + } + + if (type === 'file' || type === 'input_file') { + const fileBlock = normalizeInputFileBlock(item); + return fileBlock ? toResponsesInputFileBlock(fileBlock) : null; + } + + if (type === 'function_call' || type === 'function_call_output') { + return item; + } + + return item; +} + +export function normalizeResponsesMessageContent( + content: unknown, + role: string, +): unknown { + if (Array.isArray(content)) { + const normalized = content + .map((item) => normalizeResponsesContentItem(item, role)) + .filter((item): item is Record => !!item); + return normalized.length > 0 ? normalized : content; + } + + const single = normalizeResponsesContentItem(content, role); + if (single) return [single]; + return content; +} + +function toResponsesInputMessageFromText(text: string): Record { + return { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text }], + }; +} + +export function normalizeResponsesMessageItem(item: Record): Record { + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'function_call' || type === 'function_call_output') { + return item; + } + + const role = asTrimmedString(item.role).toLowerCase() || 'user'; + const normalizedContent = normalizeResponsesMessageContent(item.content ?? item.text, role); + + if (type === 'message') { + return { + ...item, + role, + content: normalizedContent, + }; + } + + if (asTrimmedString(item.role)) { + return { + type: 'message', + role, + content: normalizedContent, + }; + } + + if (typeof item.content === 'string') { + const text = item.content.trim(); + return text ? toResponsesInputMessageFromText(text) : item; + } + + return item; +} + +export function normalizeResponsesInputForCompatibility(input: unknown): unknown { + if (typeof input === 'string') { + const normalized = input.trim(); + if (!normalized) return input; + return [toResponsesInputMessageFromText(normalized)]; + } + + if (Array.isArray(input)) { + return input.map((item) => { + if (typeof item === 'string') { + const normalized = item.trim(); + return normalized ? toResponsesInputMessageFromText(normalized) : item; + } + if (!isRecord(item)) return item; + return normalizeResponsesMessageItem(item); + }); + } + + if (isRecord(input)) { + return [normalizeResponsesMessageItem(input)]; + } + + return input; +} + +export function normalizeResponsesMessageContentBlocks( + role: string, + content: unknown, +): Array> { + const normalized = normalizeResponsesMessageItem({ + type: 'message', + role, + content, + }); + + if (isRecord(normalized) && Array.isArray(normalized.content)) { + return normalized.content.filter((item): item is Record => isRecord(item)); + } + + return []; +} diff --git a/src/shared/tokenRouteContract.d.ts b/src/shared/tokenRouteContract.d.ts new file mode 100644 index 0000000..5595a53 --- /dev/null +++ b/src/shared/tokenRouteContract.d.ts @@ -0,0 +1,25 @@ +export type RouteMode = 'pattern' | 'explicit_group'; +export type RouteDecisionCandidate = { + channelId: number; + accountId: number; + username: string; + siteName: string; + tokenName: string; + priority: number; + weight: number; + eligible: boolean; + recentlyFailed: boolean; + avoidedByRecentFailure: boolean; + probability: number; + reason: string; +}; +export type RouteDecision = { + requestedModel: string; + actualModel: string; + matched: boolean; + selectedChannelId?: number; + selectedLabel?: string; + summary: string[]; + candidates: RouteDecisionCandidate[]; +}; +export declare function normalizeTokenRouteMode(routeMode: unknown): RouteMode; diff --git a/src/shared/tokenRouteContract.js b/src/shared/tokenRouteContract.js new file mode 100644 index 0000000..c5d0667 --- /dev/null +++ b/src/shared/tokenRouteContract.js @@ -0,0 +1,3 @@ +export function normalizeTokenRouteMode(routeMode) { + return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; +} diff --git a/src/shared/tokenRouteContract.test.ts b/src/shared/tokenRouteContract.test.ts new file mode 100644 index 0000000..80ef1ad --- /dev/null +++ b/src/shared/tokenRouteContract.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +describe('token route contract', () => { + it('normalizes unknown route modes to pattern', async () => { + const { normalizeTokenRouteMode } = await import('./tokenRouteContract.js'); + + expect(normalizeTokenRouteMode('explicit_group')).toBe('explicit_group'); + expect(normalizeTokenRouteMode('pattern')).toBe('pattern'); + expect(normalizeTokenRouteMode('anything-else')).toBe('pattern'); + expect(normalizeTokenRouteMode(null)).toBe('pattern'); + }); +}); diff --git a/src/shared/tokenRoutePatterns.d.ts b/src/shared/tokenRoutePatterns.d.ts new file mode 100644 index 0000000..82c801e --- /dev/null +++ b/src/shared/tokenRoutePatterns.d.ts @@ -0,0 +1,7 @@ +export declare function isTokenRouteRegexPattern(pattern: string): boolean; +export declare function isExactTokenRouteModelPattern(pattern: string): boolean; +export declare function parseTokenRouteRegexPattern(pattern: string): { + regex: RegExp | null; + error: string | null; +}; +export declare function matchesTokenRouteModelPattern(model: string, pattern: string): boolean; diff --git a/src/shared/tokenRoutePatterns.js b/src/shared/tokenRoutePatterns.js new file mode 100644 index 0000000..534fb0b --- /dev/null +++ b/src/shared/tokenRoutePatterns.js @@ -0,0 +1,190 @@ +function readRegexQuantifierLength(pattern, startIndex) { + const ch = pattern[startIndex]; + if (ch === '*' || ch === '+' || ch === '?') + return 1; + if (ch !== '{') + return 0; + let index = startIndex + 1; + let sawDigit = false; + while (index < pattern.length && /\d/.test(pattern[index])) { + sawDigit = true; + index += 1; + } + if (!sawDigit) + return 0; + if (pattern[index] === ',') { + index += 1; + while (index < pattern.length && /\d/.test(pattern[index])) { + index += 1; + } + } + if (pattern[index] !== '}') + return 0; + return index - startIndex + 1; +} +function isSafeRegexPatternBody(body) { + if (!body || body.length > 256) + return false; + if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) + return false; + if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(? 0) { + groupStack[groupStack.length - 1].hasAlternation = true; + } + continue; + } + if (ch === ')') { + const group = groupStack.pop(); + if (!group) + return false; + const quantifierLength = readRegexQuantifierLength(body, index + 1); + if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) { + return false; + } + const parent = groupStack[groupStack.length - 1]; + if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) { + parent.hasInnerQuantifier = true; + } + continue; + } + const quantifierLength = readRegexQuantifierLength(body, index); + if (quantifierLength > 0) { + if (groupStack.length > 0) { + groupStack[groupStack.length - 1].hasInnerQuantifier = true; + } + index += quantifierLength - 1; + } + } + return !escaped && !inCharClass && groupStack.length === 0; +} +function globToRegexSource(glob) { + let source = ''; + for (let i = 0; i < glob.length; i += 1) { + const ch = glob[i]; + if (ch === '*') { + source += '.*'; + continue; + } + if (ch === '?') { + source += '.'; + continue; + } + if (ch === '[') { + const closeIndex = glob.indexOf(']', i + 1); + if (closeIndex > i + 1) { + source += glob.slice(i, closeIndex + 1); + i = closeIndex; + continue; + } + source += '\\['; + continue; + } + source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&'); + } + return source; +} +const compiledGlobCache = new Map(); +const matchCache = new Map(); +const MATCH_CACHE_LIMIT = 4000; +function matchesGlobPattern(model, pattern) { + let re = compiledGlobCache.get(pattern); + if (re === undefined) { + try { + re = new RegExp(`^${globToRegexSource(pattern)}$`); + } + catch { + re = null; + } + compiledGlobCache.set(pattern, re); + } + return re ? re.test(model) : false; +} +export function isTokenRouteRegexPattern(pattern) { + return pattern.trim().toLowerCase().startsWith('re:'); +} +export function isExactTokenRouteModelPattern(pattern) { + const normalized = pattern.trim(); + if (!normalized) + return false; + if (isTokenRouteRegexPattern(normalized)) + return false; + return !/[\*\?]/.test(normalized); +} +export function parseTokenRouteRegexPattern(pattern) { + if (!isTokenRouteRegexPattern(pattern)) + return { regex: null, error: null }; + const body = pattern.trim().slice(3).trim(); + if (!body) + return { regex: null, error: 're: 后缺少正则表达式' }; + if (!isSafeRegexPatternBody(body)) { + return { regex: null, error: '出于安全原因不支持该正则表达式' }; + } + try { + return { regex: new RegExp(body), error: null }; + } + catch (error) { + return { regex: null, error: error?.message || '无效正则' }; + } +} +export function matchesTokenRouteModelPattern(model, pattern) { + const normalized = (pattern || '').trim(); + if (!normalized) + return false; + if (normalized === model) + return true; + const cacheKey = `${model}\0${normalized}`; + const cached = matchCache.get(cacheKey); + if (cached !== undefined) + return cached; + let result; + if (isTokenRouteRegexPattern(normalized)) { + const parsed = parseTokenRouteRegexPattern(normalized); + result = !!parsed.regex && parsed.regex.test(model); + } + else { + result = matchesGlobPattern(model, normalized); + } + if (matchCache.size >= MATCH_CACHE_LIMIT) + matchCache.clear(); + matchCache.set(cacheKey, result); + return result; +} diff --git a/src/shared/tokenRoutePatterns.test.ts b/src/shared/tokenRoutePatterns.test.ts new file mode 100644 index 0000000..72cd4a5 --- /dev/null +++ b/src/shared/tokenRoutePatterns.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +describe('token route pattern helpers', () => { + it('treats bracket-prefixed literal model names as exact patterns', async () => { + const { isExactTokenRouteModelPattern } = await import('./tokenRoutePatterns.js'); + expect(isExactTokenRouteModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true); + }); + + it('rejects unsafe nested-quantifier regex patterns', async () => { + const { + matchesTokenRouteModelPattern, + parseTokenRouteRegexPattern, + } = await import('./tokenRoutePatterns.js'); + + expect(parseTokenRouteRegexPattern('re:^(a+)+$').regex).toBeNull(); + expect(matchesTokenRouteModelPattern('aaaa', 're:^(a+)+$')).toBe(false); + }); + + it('supports exact, glob, and safe regex route matches', async () => { + const { matchesTokenRouteModelPattern } = await import('./tokenRoutePatterns.js'); + + expect(matchesTokenRouteModelPattern('gpt-4o-mini', 'gpt-4o-mini')).toBe(true); + expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 'claude-*')).toBe(true); + expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:^claude-(opus|sonnet)-4-6$')).toBe(true); + }); +}); diff --git a/src/web/api.test.ts b/src/web/api.test.ts index 69297ad..8ae56c9 100644 --- a/src/web/api.test.ts +++ b/src/web/api.test.ts @@ -149,4 +149,9 @@ describe('api proxy test timeout handling', () => { data: 'data:application/pdf;base64,UERG', }); }); + + it('reuses the same proxy test implementations for legacy aliases', () => { + expect(api.proxyTest).toBe(api.testProxy); + expect(api.proxyTestStream).toBe(api.testProxyStream); + }); }); diff --git a/src/web/api.ts b/src/web/api.ts index 9b76f6e..76e83f1 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -194,6 +194,31 @@ function resolveProxyTestTimeoutMs(data: ProxyTestRequestEnvelope) { return DEFAULT_PROXY_TEST_TIMEOUT_MS; } +function proxyTestRequest(data: ProxyTestRequestEnvelope) { + return request('/api/test/proxy', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: resolveProxyTestTimeoutMs(data), + }); +} + +async function proxyTestStreamRequest(data: ProxyTestRequestEnvelope, signal?: AbortSignal) { + const token = getAuthToken(localStorage); + if (!token) { + clearAuthSession(localStorage); + throw new Error('Session expired'); + } + return fetch('/api/test/proxy/stream', { + method: 'POST', + signal, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); +} + export type ProxyTestJobResponse = { jobId: string; status: 'pending' | 'succeeded' | 'failed' | 'cancelled'; @@ -721,52 +746,12 @@ export const api = { data: `data:${mimeType};base64,${base64}`, }; }, - testProxy: (data: ProxyTestRequestEnvelope) => - request('/api/test/proxy', { - method: 'POST', - body: JSON.stringify(data), - timeoutMs: resolveProxyTestTimeoutMs(data), - }), - proxyTest: (data: ProxyTestRequestEnvelope) => - request('/api/test/proxy', { - method: 'POST', - body: JSON.stringify(data), - timeoutMs: resolveProxyTestTimeoutMs(data), - }), + testProxy: proxyTestRequest, + proxyTest: proxyTestRequest, testChat: (data: TestChatRequestPayload) => request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }), - testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => { - const token = getAuthToken(localStorage); - if (!token) { - clearAuthSession(localStorage); - throw new Error('Session expired'); - } - return fetch('/api/test/proxy/stream', { - method: 'POST', - signal, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); - }, - proxyTestStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => { - const token = getAuthToken(localStorage); - if (!token) { - clearAuthSession(localStorage); - throw new Error('Session expired'); - } - return fetch('/api/test/proxy/stream', { - method: 'POST', - signal, - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); - }, + testProxyStream: proxyTestStreamRequest, + proxyTestStream: proxyTestStreamRequest, testChatStream: async (data: TestChatRequestPayload, signal?: AbortSignal) => { const token = getAuthToken(localStorage); if (!token) { diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 7df1f55..c9ac5bc 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -16,6 +16,11 @@ import { buildVerifyFailureHint, normalizeVerifyFailureMessage, } from './helpers/accountVerifyFeedback.js'; +import { + isTruthyFlag, + parsePositiveInt, + resolveAccountCredentialMode, +} from './helpers/accountConnection.js'; import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js'; import { TokensPanel } from './Tokens.js'; import { tr } from '../i18n.js'; @@ -58,17 +63,6 @@ function createRebindForm(platformUserId = '') { return { accessToken: '', platformUserId, refreshToken: '', tokenExpiresAt: '' }; } -function isTruthyFlag(value: string | null): boolean { - if (!value) return false; - const normalized = value.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes'; -} - -function parsePositiveInt(value: string | null): number { - const parsed = Number.parseInt(String(value || '').trim(), 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; -} - function resolveConnectionsSegment(search: string): ConnectionsSegment { const rawSegment = new URLSearchParams(search).get('segment'); if (rawSegment === 'apikey' || rawSegment === 'tokens') return rawSegment; @@ -194,18 +188,6 @@ export default function Accounts() { resetAddForms(); }; - const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => { - const rawMode = String(account?.credentialMode || '').trim().toLowerCase(); - if (rawMode === 'apikey') return 'apikey'; - if (rawMode === 'session') return 'session'; - const fromServer = account?.capabilities; - if (fromServer && typeof fromServer.proxyOnly === 'boolean') { - return fromServer.proxyOnly ? 'apikey' : 'session'; - } - const hasSession = typeof account?.accessToken === 'string' && account.accessToken.trim().length > 0; - return hasSession ? 'session' : 'apikey'; - }; - const resolveAccountDisplayName = (account: any) => { const username = typeof account?.username === 'string' ? account.username.trim() : ''; if (username) return username; diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index a3ea17c..c48a670 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -6,7 +6,7 @@ import { useToast } from '../components/Toast.js'; import { useIsMobile } from '../components/useIsMobile.js'; import ChangeKeyModal from '../components/ChangeKeyModal.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; -import { BrandGlyph, InlineBrandIcon, getBrand, normalizeBrandIconKey } from '../components/BrandIcon.js'; +import { BrandGlyph, InlineBrandIcon, getBrand } from '../components/BrandIcon.js'; import ModernSelect from '../components/ModernSelect.js'; import { applyRoutingProfilePreset, @@ -17,11 +17,15 @@ import { fuzzyMatch } from './helpers/fuzzySearch.js'; import { clearAuthSession } from '../authSession.js'; import { clearAppInstallationState } from '../appLocalState.js'; import { tr } from '../i18n.js'; -import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js'; +import { + isExactModelPattern, + parseBrandIconValue, + resolveRouteTitle, + ROUTE_ICON_NONE_VALUE, +} from './token-routes/utils.js'; import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js'; const PROXY_TOKEN_PREFIX = 'sk-'; -const ROUTE_BRAND_ICON_PREFIX = 'brand:'; const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token'; const FACTORY_RESET_CONFIRM_SECONDS = 3; const CHECKIN_SCHEDULE_MODE_OPTIONS = [ @@ -174,31 +178,8 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null return null; } -function isRegexModelPattern(pattern: string): boolean { - return pattern.trim().toLowerCase().startsWith('re:'); -} - -function isExactModelPattern(modelPattern: string): boolean { - const normalized = modelPattern.trim(); - if (!normalized) return false; - if (isRegexModelPattern(normalized)) return false; - return !/[\*\?]/.test(normalized); -} - -function routeTitle(route: RouteSelectorItem): string { - const displayName = (route.displayName || '').trim(); - return displayName || route.modelPattern; -} - -function parseBrandIconValue(raw: string | null | undefined): string | null { - const normalized = (raw || '').trim(); - if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null; - const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim(); - return normalizeBrandIconKey(icon); -} - function resolveRouteBrandSource(route: RouteSelectorItem): string { - const title = routeTitle(route); + const title = resolveRouteTitle(route); if (getBrand(title)) return title; return route.modelPattern; } @@ -302,7 +283,7 @@ export default function Settings() { const groupRouteOptions = useMemo(() => ( selectorRoutes .filter((route) => !isExactModelPattern(route.modelPattern)) - .sort((a, b) => routeTitle(a).localeCompare(routeTitle(b), undefined, { sensitivity: 'base' })) + .sort((a, b) => resolveRouteTitle(a).localeCompare(resolveRouteTitle(b), undefined, { sensitivity: 'base' })) ), [selectorRoutes]); const filteredExactModelOptions = useMemo(() => { @@ -315,7 +296,7 @@ export default function Settings() { const query = selectorGroupSearch.trim(); if (!query) return groupRouteOptions; return groupRouteOptions.filter((route) => { - const matchText = `${routeTitle(route)} ${route.modelPattern} ${route.displayName || ''}`; + const matchText = `${resolveRouteTitle(route)} ${route.modelPattern} ${route.displayName || ''}`; return fuzzyMatch(matchText, query); }); }, [groupRouteOptions, selectorGroupSearch]); @@ -1926,7 +1907,7 @@ export default function Settings() {
没有匹配的群组
) : filteredGroupRouteOptions.map((route) => { const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id); - const explicitBrandIcon = parseBrandIconValue(route.displayIcon); + const explicitBrandIcon = parseBrandIconValue(route.displayIcon || ''); const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE; const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim(); return ( @@ -1985,9 +1966,9 @@ export default function Settings() { {explicitBrandIcon ? ( ) : textIcon ? ( textIcon @@ -1996,7 +1977,7 @@ export default function Settings() { )} - {routeTitle(route)} + {resolveRouteTitle(route)} {!route.enabled && ( { - const rawMode = String(account?.credentialMode || '').trim().toLowerCase(); - if (rawMode === 'apikey') return 'apikey'; - if (rawMode === 'session') return 'session'; - if (typeof account?.capabilities?.proxyOnly === 'boolean') { - return account.capabilities.proxyOnly ? 'apikey' : 'session'; - } - return typeof account?.accessToken === 'string' && account.accessToken.trim() - ? 'session' - : 'apikey'; -}; - const isAccountSyncable = (account: any) => resolveAccountCredentialMode(account) === 'session' && account?.status === 'active' @@ -105,18 +98,6 @@ async function copyText(text: string) { document.body.removeChild(textarea); } -function parsePositiveInt(input: string | null): number { - const value = Number.parseInt(String(input || '').trim(), 10); - if (!Number.isFinite(value) || value <= 0) return 0; - return value; -} - -function isTruthyFlag(input: string | null): boolean { - if (!input) return false; - const normalized = input.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes'; -} - export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: TokensPanelProps) { const location = useLocation(); const navigate = useNavigate(); diff --git a/src/web/pages/helpers/accountConnection.test.ts b/src/web/pages/helpers/accountConnection.test.ts new file mode 100644 index 0000000..b44a415 --- /dev/null +++ b/src/web/pages/helpers/accountConnection.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { + isTruthyFlag, + parsePositiveInt, + resolveAccountCredentialMode, +} from './accountConnection.js'; + +describe('accountConnection helpers', () => { + it('resolves account credential mode from explicit mode, capabilities, and token presence', () => { + expect(resolveAccountCredentialMode({ credentialMode: 'apikey' })).toBe('apikey'); + expect(resolveAccountCredentialMode({ capabilities: { proxyOnly: true } })).toBe('apikey'); + expect(resolveAccountCredentialMode({ accessToken: ' session-token ' })).toBe('session'); + expect(resolveAccountCredentialMode({})).toBe('apikey'); + }); + + it('parses positive integers from query values', () => { + expect(parsePositiveInt('42')).toBe(42); + expect(parsePositiveInt(' 0 ')).toBe(0); + expect(parsePositiveInt('abc')).toBe(0); + expect(parsePositiveInt(null)).toBe(0); + }); + + it('treats common truthy query flags as enabled', () => { + expect(isTruthyFlag('1')).toBe(true); + expect(isTruthyFlag(' TRUE ')).toBe(true); + expect(isTruthyFlag('yes')).toBe(true); + expect(isTruthyFlag('no')).toBe(false); + expect(isTruthyFlag(null)).toBe(false); + }); +}); diff --git a/src/web/pages/helpers/accountConnection.ts b/src/web/pages/helpers/accountConnection.ts new file mode 100644 index 0000000..bc55524 --- /dev/null +++ b/src/web/pages/helpers/accountConnection.ts @@ -0,0 +1,23 @@ +export function resolveAccountCredentialMode(account: any): 'session' | 'apikey' { + const rawMode = String(account?.credentialMode || '').trim().toLowerCase(); + if (rawMode === 'apikey') return 'apikey'; + if (rawMode === 'session') return 'session'; + if (typeof account?.capabilities?.proxyOnly === 'boolean') { + return account.capabilities.proxyOnly ? 'apikey' : 'session'; + } + return typeof account?.accessToken === 'string' && account.accessToken.trim() + ? 'session' + : 'apikey'; +} + +export function parsePositiveInt(input: string | null): number { + const value = Number.parseInt(String(input || '').trim(), 10); + if (!Number.isFinite(value) || value <= 0) return 0; + return value; +} + +export function isTruthyFlag(input: string | null): boolean { + if (!input) return false; + const normalized = input.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; +} diff --git a/src/web/pages/helpers/routeListVisibility.ts b/src/web/pages/helpers/routeListVisibility.ts index 8c879e6..0199e93 100644 --- a/src/web/pages/helpers/routeListVisibility.ts +++ b/src/web/pages/helpers/routeListVisibility.ts @@ -1,3 +1,5 @@ +import { normalizeTokenRouteMode } from '../../../shared/tokenRouteContract.js'; + export type RouteListVisibilityItem = { id: number; modelPattern: string; @@ -8,7 +10,7 @@ export type RouteListVisibilityItem = { }; function normalizeRouteMode(routeMode: string | null | undefined): 'pattern' | 'explicit_group' { - return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; + return normalizeTokenRouteMode(routeMode); } function isExplicitGroupRoute(route: Pick): boolean { diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index 6474033..e8fc2e7 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -1,13 +1,12 @@ import type { ReactNode } from 'react'; import type { BrandInfo } from '../../components/BrandIcon.js'; +import type { RouteDecision, RouteDecisionCandidate, RouteMode } from '../../../shared/tokenRouteContract.js'; export type RouteSortBy = 'modelPattern' | 'channelCount'; export type RouteSortDir = 'asc' | 'desc'; export type GroupFilter = null | '__all__' | number; export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first'; export type RouteRowKind = 'persisted' | 'zero_channel'; -export type RouteMode = 'pattern' | 'explicit_group'; - export type RouteChannelDraft = { accountId: number; tokenId: number; @@ -81,31 +80,6 @@ export type RouteSummaryRow = { isVirtual?: boolean; }; -export type RouteDecisionCandidate = { - channelId: number; - accountId: number; - username: string; - siteName: string; - tokenName: string; - priority: number; - weight: number; - eligible: boolean; - recentlyFailed: boolean; - avoidedByRecentFailure: boolean; - probability: number; - reason: string; -}; - -export type RouteDecision = { - requestedModel: string; - actualModel: string; - matched: boolean; - selectedChannelId?: number; - selectedLabel?: string; - summary: string[]; - candidates: RouteDecisionCandidate[]; -}; - export type ChannelDecisionState = { probability: number; showBar: boolean; diff --git a/src/web/pages/token-routes/utils.ts b/src/web/pages/token-routes/utils.ts index 9e6da8b..853db4d 100644 --- a/src/web/pages/token-routes/utils.ts +++ b/src/web/pages/token-routes/utils.ts @@ -1,6 +1,13 @@ import type { CSSProperties } from 'react'; import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js'; -import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow, RouteMode } from './types.js'; +import { normalizeTokenRouteMode, type RouteDecisionCandidate, type RouteMode } from '../../../shared/tokenRouteContract.js'; +import type { RouteRow, RouteChannel, ChannelDecisionState, RouteSummaryRow } from './types.js'; +import { + isExactTokenRouteModelPattern, + isTokenRouteRegexPattern, + matchesTokenRouteModelPattern, + parseTokenRouteRegexPattern, +} from '../../../shared/tokenRoutePatterns.js'; export const AUTO_ROUTE_DECISION_LIMIT = 80; export const ROUTE_RENDER_CHUNK = 40; @@ -39,18 +46,15 @@ export const PLATFORM_ALIASES: Record = { }; export function isRegexModelPattern(modelPattern: string): boolean { - return modelPattern.trim().toLowerCase().startsWith('re:'); + return isTokenRouteRegexPattern(modelPattern); } export function isExactModelPattern(modelPattern: string): boolean { - const normalized = modelPattern.trim(); - if (!normalized) return false; - if (isRegexModelPattern(normalized)) return false; - return !/[\*\?]/.test(normalized); + return isExactTokenRouteModelPattern(modelPattern); } export function normalizeRouteMode(routeMode: RouteMode | string | null | undefined): RouteMode { - return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; + return normalizeTokenRouteMode(routeMode); } export function isExplicitGroupRoute(route: Pick): boolean { @@ -62,81 +66,11 @@ export function isRouteExactModel(route: Pick i + 1) { - source += glob.slice(i, closeIndex + 1); - i = closeIndex; - continue; - } - source += '\\['; - continue; - } - source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&'); - } - return source; -} - -const compiledGlobCache = new Map(); - -function matchesGlobPattern(model: string, pattern: string): boolean { - let re = compiledGlobCache.get(pattern); - if (re === undefined) { - try { - re = new RegExp(`^${globToRegexSource(pattern)}$`); - } catch { - re = null; - } - compiledGlobCache.set(pattern, re); - } - return re ? re.test(model) : false; -} - -const matchCache = new Map(); -const MATCH_CACHE_LIMIT = 4000; - export function matchesModelPattern(model: string, pattern: string): boolean { - const normalized = (pattern || '').trim(); - if (!normalized) return false; - if (normalized === model) return true; - - const cacheKey = `${model}\0${normalized}`; - const cached = matchCache.get(cacheKey); - if (cached !== undefined) return cached; - - let result: boolean; - if (isRegexModelPattern(normalized)) { - const parsed = parseRegexModelPattern(normalized); - result = !!parsed.regex && parsed.regex.test(model); - } else { - result = matchesGlobPattern(model, normalized); - } - - if (matchCache.size >= MATCH_CACHE_LIMIT) matchCache.clear(); - matchCache.set(cacheKey, result); - return result; + return matchesTokenRouteModelPattern(model, pattern); } export function getModelPatternError(modelPattern: string): string | null { From 32d7037629b280532471511e76bef42f01dfc336 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 22 Mar 2026 22:17:50 +0800 Subject: [PATCH 03/24] refactor: share platform file and route helpers --- .../conversationFileCapabilities.ts | 10 +-- .../proxy-core/surfaces/filesSurface.ts | 12 +--- src/server/routes/proxy/logPathMeta.ts | 18 +---- src/server/services/backupService.ts | 71 +++--------------- src/server/services/platforms/claude.ts | 5 +- src/server/services/platforms/index.ts | 72 +------------------ src/server/services/proxyInputFileResolver.ts | 8 +-- .../gemini/generate-content/index.test.ts | 14 +++- .../gemini/generate-content/index.ts | 48 +++---------- .../gemini/generate-content/outbound.ts | 50 +++---------- .../gemini/generate-content/urlResolver.ts | 38 ++++++++++ src/shared/conversationFileTypes.d.ts | 16 +++++ src/shared/conversationFileTypes.js | 52 ++++++++++++++ src/shared/conversationFileTypes.test.ts | 34 +++++++++ src/shared/platformIdentity.d.ts | 3 + src/shared/platformIdentity.js | 65 +++++++++++++++++ src/shared/platformIdentity.test.ts | 24 +++++++ src/shared/proxyLogMeta.d.ts | 9 +++ src/shared/proxyLogMeta.js | 18 +++++ src/shared/proxyLogMeta.test.ts | 27 +++++++ src/web/pages/Settings.tsx | 38 +++++----- .../helpers/conversationFileCapabilities.ts | 47 +++--------- src/web/pages/helpers/navigationFocus.ts | 8 +-- src/web/pages/helpers/proxyLogPathMeta.ts | 21 +++--- 24 files changed, 375 insertions(+), 333 deletions(-) create mode 100644 src/server/transformers/gemini/generate-content/urlResolver.ts create mode 100644 src/shared/conversationFileTypes.d.ts create mode 100644 src/shared/conversationFileTypes.js create mode 100644 src/shared/conversationFileTypes.test.ts create mode 100644 src/shared/platformIdentity.d.ts create mode 100644 src/shared/platformIdentity.js create mode 100644 src/shared/platformIdentity.test.ts create mode 100644 src/shared/proxyLogMeta.d.ts create mode 100644 src/shared/proxyLogMeta.js create mode 100644 src/shared/proxyLogMeta.test.ts diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts index b4b4882..bd46e90 100644 --- a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts +++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts @@ -1,4 +1,5 @@ import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; +import { classifyConversationFileMimeType } from '../../../shared/conversationFileTypes.js'; export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native'; export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses'; @@ -25,13 +26,6 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function classifyMimeFamily(mimeType: string | null): 'image' | 'audio' | 'document' { - const normalized = asTrimmedString(mimeType).toLowerCase(); - if (normalized.startsWith('image/')) return 'image'; - if (normalized.startsWith('audio/')) return 'audio'; - return 'document'; -} - function appendConversationFileSummary( summary: ConversationFileInputSummary, item: Record, @@ -39,7 +33,7 @@ function appendConversationFileSummary( const normalizedFile = normalizeInputFileBlock(item); if (normalizedFile) { const mimeType = inferInputFileMimeType(normalizedFile); - const family = classifyMimeFamily(mimeType); + const family = classifyConversationFileMimeType(mimeType); if (family === 'image') { summary.hasImage = true; return; diff --git a/src/server/proxy-core/surfaces/filesSurface.ts b/src/server/proxy-core/surfaces/filesSurface.ts index fdb3b87..3194e25 100644 --- a/src/server/proxy-core/surfaces/filesSurface.ts +++ b/src/server/proxy-core/surfaces/filesSurface.ts @@ -8,6 +8,7 @@ import { softDeleteProxyFileByPublicIdForOwner, } from '../../services/proxyFileStore.js'; import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js'; +import { isSupportedConversationFileMimeType } from '../../../shared/conversationFileTypes.js'; function invalidRequest(reply: FastifyReply, message: string) { return reply.code(400).send({ error: { message, type: 'invalid_request_error' } }); @@ -58,15 +59,6 @@ function inferMimeTypeFromFilename(filename: string): string { return 'application/octet-stream'; } -function isSupportedProxyFileMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); -} - export async function filesProxyRoute(app: FastifyInstance) { ensureMultipartBufferParser(app); @@ -88,7 +80,7 @@ export async function filesProxyRoute(app: FastifyInstance) { const filename = fileEntry.name || 'upload.bin'; const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase(); - if (!isSupportedProxyFileMimeType(mimeType)) { + if (!isSupportedConversationFileMimeType(mimeType)) { return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`); } diff --git a/src/server/routes/proxy/logPathMeta.ts b/src/server/routes/proxy/logPathMeta.ts index 9b72cde..35a3175 100644 --- a/src/server/routes/proxy/logPathMeta.ts +++ b/src/server/routes/proxy/logPathMeta.ts @@ -1,3 +1,5 @@ +import { parseProxyLogMetadata } from '../../../shared/proxyLogMeta.js'; + type ComposeProxyLogMessageArgs = { clientKind?: string | null; sessionId?: string | null; @@ -16,21 +18,7 @@ export type ParsedProxyLogMessageMeta = { }; export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta { - const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i); - const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i); - const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i); - const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i); - const messageText = rawMessage.replace( - /^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i, - '', - ).trim(); - return { - clientKind: clientMatch?.[1]?.trim() || null, - sessionId: sessionMatch?.[1]?.trim() || null, - downstreamPath: downstreamMatch?.[1]?.trim() || null, - upstreamPath: upstreamMatch?.[1]?.trim() || null, - messageText, - }; + return parseProxyLogMetadata(rawMessage); } export function composeProxyLogMessage({ diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 4ea984f..93227db 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js'; import { upsertSetting } from '../db/upsertSetting.js'; import { mergeAccountExtraConfig } from './accountExtraConfig.js'; import { getOauthInfoFromAccount } from './oauth/oauthAccount.js'; +import { PLATFORM_ALIASES, detectPlatformByUrlHint } from '../../shared/platformIdentity.js'; const BACKUP_VERSION = '2.1'; @@ -244,38 +245,6 @@ const DIRECT_API_PLATFORMS = new Set([ 'antigravity', ]); -const IMPORT_PLATFORM_ALIASES: Record = { - anyrouter: 'anyrouter', - 'wong-gongyi': 'new-api', - 'vo-api': 'new-api', - 'super-api': 'new-api', - 'rix-api': 'new-api', - 'neo-api': 'new-api', - newapi: 'new-api', - 'new api': 'new-api', - 'new-api': 'new-api', - oneapi: 'one-api', - 'one api': 'one-api', - 'one-api': 'one-api', - onehub: 'one-hub', - 'one-hub': 'one-hub', - donehub: 'done-hub', - 'done-hub': 'done-hub', - veloera: 'veloera', - sub2api: 'sub2api', - openai: 'openai', - anthropic: 'claude', - claude: 'claude', - google: 'gemini', - gemini: 'gemini', - cliproxyapi: 'cliproxyapi', - cpa: 'cliproxyapi', - 'cli-proxy-api': 'cliproxyapi', - codex: 'codex', - 'gemini-cli': 'gemini-cli', - antigravity: 'antigravity', -}; - function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -645,38 +614,14 @@ function normalizeOriginUrl(raw: string): string { } } -function detectLocalPlatformByUrlHint(url: string): string | undefined { - const normalized = url.trim().toLowerCase(); - if (!normalized) return undefined; - - if (normalized.includes('api.openai.com')) return 'openai'; - if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; - if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; - if ( - normalized.includes('generativelanguage.googleapis.com') - || normalized.includes('googleapis.com/v1beta/openai') - || normalized.includes('gemini.google.com') - ) { - return 'gemini'; - } - if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; - if (normalized.includes('anyrouter')) return 'anyrouter'; - if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; - if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; - if (normalized.includes('veloera')) return 'veloera'; - if (normalized.includes('sub2api')) return 'sub2api'; - if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; - - return undefined; -} - function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string { - const normalizedPlatform = typeof rawPlatform === 'string' - ? IMPORT_PLATFORM_ALIASES[rawPlatform.trim().toLowerCase()] - : undefined; + const rawPlatformText = asString(rawPlatform).toLowerCase(); + const normalizedPlatform = rawPlatformText + ? (PLATFORM_ALIASES[rawPlatformText] ?? (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '')) + : ''; if (normalizedPlatform) return normalizedPlatform; - const urlHint = detectLocalPlatformByUrlHint(rawUrl); + const urlHint = detectPlatformByUrlHint(rawUrl); if (urlHint) return urlHint; return normalizeLegacyPlatform(asString(rawPlatform)); @@ -688,9 +633,9 @@ function resolveImportedProfilePlatform(apiType: unknown, baseUrl: string): stri if (normalizedType === 'anthropic') return 'claude'; if (normalizedType === 'google') return 'gemini'; if (normalizedType === 'openai-compatible') { - return detectLocalPlatformByUrlHint(baseUrl) || 'openai'; + return detectPlatformByUrlHint(baseUrl) || 'openai'; } - return detectLocalPlatformByUrlHint(baseUrl) || 'openai'; + return detectPlatformByUrlHint(baseUrl) || 'openai'; } function pushDefaultImportedToken( diff --git a/src/server/services/platforms/claude.ts b/src/server/services/platforms/claude.ts index 28a7e79..d56a528 100644 --- a/src/server/services/platforms/claude.ts +++ b/src/server/services/platforms/claude.ts @@ -1,6 +1,5 @@ import { StandardApiProviderAdapterBase } from './standardApiProvider.js'; - -const DEFAULT_ANTHROPIC_VERSION = '2023-06-01'; +import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from '../oauth/claudeProvider.js'; export class ClaudeAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'claude'; @@ -15,7 +14,7 @@ export class ClaudeAdapter extends StandardApiProviderAdapterBase { baseUrl, headers: { 'x-api-key': apiToken, - 'anthropic-version': DEFAULT_ANTHROPIC_VERSION, + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, }, }); } diff --git a/src/server/services/platforms/index.ts b/src/server/services/platforms/index.ts index 1fc4333..aceb911 100644 --- a/src/server/services/platforms/index.ts +++ b/src/server/services/platforms/index.ts @@ -14,6 +14,7 @@ import { GeminiCliAdapter } from './geminiCli.js'; import { AntigravityAdapter } from './antigravity.js'; import { CliProxyApiAdapter } from './cliproxyapi.js'; import { detectPlatformByTitle } from './titleHint.js'; +import { detectPlatformByUrlHint, normalizePlatformAlias } from '../../../shared/platformIdentity.js'; const adapters: PlatformAdapter[] = [ // Specific forks before generic adapters for better auto-detection. @@ -33,47 +34,8 @@ const adapters: PlatformAdapter[] = [ new OneApiAdapter(), ]; -const platformAliases: Record = { - // NewAPI family aliases - anyrouter: 'anyrouter', - 'wong-gongyi': 'new-api', - 'vo-api': 'new-api', - 'super-api': 'new-api', - 'rix-api': 'new-api', - 'neo-api': 'new-api', - newapi: 'new-api', - 'new api': 'new-api', - // OneAPI family aliases - oneapi: 'one-api', - 'one api': 'one-api', - // Keep canonical forms explicit for clarity - 'new-api': 'new-api', - 'one-api': 'one-api', - veloera: 'veloera', - 'one-hub': 'one-hub', - 'done-hub': 'done-hub', - sub2api: 'sub2api', - // Official upstream APIs - openai: 'openai', - codex: 'codex', - 'chatgpt-codex': 'codex', - 'chatgpt codex': 'codex', - anthropic: 'claude', - claude: 'claude', - gemini: 'gemini', - 'gemini-cli': 'gemini-cli', - antigravity: 'antigravity', - 'anti-gravity': 'antigravity', - google: 'gemini', - // CLIProxyAPI aliases - cliproxyapi: 'cliproxyapi', - cpa: 'cliproxyapi', - 'cli-proxy-api': 'cliproxyapi', -}; - function normalizePlatform(platform: string): string { - const raw = (platform || '').trim().toLowerCase(); - return platformAliases[raw] ?? raw; + return normalizePlatformAlias(platform); } export function getAdapter(platform: string): PlatformAdapter | undefined { @@ -89,36 +51,6 @@ const titleFirstPlatforms = new Set([ 'sub2api', ]); -function detectPlatformByUrlHint(url: string): string | undefined { - const normalized = (url || '').trim().toLowerCase(); - if (!normalized) return undefined; - - // Official upstream endpoints. - if (normalized.includes('api.openai.com')) return 'openai'; - if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; - if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; - if ( - normalized.includes('generativelanguage.googleapis.com') - || normalized.includes('googleapis.com/v1beta/openai') - || normalized.includes('gemini.google.com') - ) { - return 'gemini'; - } - if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; - - // NewAPI-family forks and common aliases. - if (normalized.includes('anyrouter')) return 'anyrouter'; - if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; - if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; - if (normalized.includes('veloera')) return 'veloera'; - if (normalized.includes('sub2api')) return 'sub2api'; - - // CLIProxyAPI default local endpoints. - if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; - - return undefined; -} - export async function detectPlatform(url: string): Promise { const urlHint = detectPlatformByUrlHint(url); if (urlHint) { diff --git a/src/server/services/proxyInputFileResolver.ts b/src/server/services/proxyInputFileResolver.ts index e3d9290..d42d08a 100644 --- a/src/server/services/proxyInputFileResolver.ts +++ b/src/server/services/proxyInputFileResolver.ts @@ -2,6 +2,7 @@ import type { ProxyResourceOwner } from '../middleware/auth.js'; import { getProxyFileByPublicIdForOwner } from './proxyFileStore.js'; import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js'; +import { isSupportedConversationFileMimeType } from '../../shared/conversationFileTypes.js'; const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-'; @@ -44,12 +45,7 @@ function inferMimeTypeFromFilename(filename: string): string { } function isSupportedMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); + return isSupportedConversationFileMimeType(mimeType); } function audioFormatFromMimeType(mimeType: string, filename: string): string { diff --git a/src/server/transformers/gemini/generate-content/index.test.ts b/src/server/transformers/gemini/generate-content/index.test.ts index ac6dad4..c94d77e 100644 --- a/src/server/transformers/gemini/generate-content/index.test.ts +++ b/src/server/transformers/gemini/generate-content/index.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest'; import { geminiGenerateContentTransformer, + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, } from './index.js'; import { extractGeminiUsage } from './usage.js'; -import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js'; +import { serializeGeminiAggregateResponse, extractResponseMetadata, geminiGenerateContentOutbound } from './outbound.js'; import { resolveGeminiThinkingConfigFromRequest } from './convert.js'; import { parseGeminiStreamPayload, @@ -15,6 +18,15 @@ import { } from './stream.js'; describe('geminiGenerateContentTransformer.inbound', () => { + it('reuses the same gemini url resolver helpers across transformer layers', () => { + expect(geminiGenerateContentTransformer.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl); + expect(geminiGenerateContentTransformer.resolveModelsUrl).toBe(resolveGeminiModelsUrl); + expect(geminiGenerateContentTransformer.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl); + expect(geminiGenerateContentOutbound.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl); + expect(geminiGenerateContentOutbound.resolveModelsUrl).toBe(resolveGeminiModelsUrl); + expect(geminiGenerateContentOutbound.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl); + }); + it('parses native Gemini requests into canonical envelopes', () => { const result = geminiGenerateContentTransformer.parseRequest({ model: 'gemini-2.5-pro', diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts index 4d71fbc..9661f88 100644 --- a/src/server/transformers/gemini/generate-content/index.ts +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -1,13 +1,16 @@ import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js'; import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js'; import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function baseIncludesVersion(baseUrl: string): boolean { - return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); -} +import { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; +export { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; @@ -17,37 +20,6 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } -export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (baseIncludesVersion(normalized)) return normalized; - return `${normalized}/${apiVersion}`; -} - -export function resolveGeminiModelsUrl( - baseUrl: string, - apiVersion: string, - apiKey: string, -): string { - const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); - const separator = base.includes('?') ? '&' : '?'; - return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`; -} - -export function resolveGeminiGenerateContentUrl( - baseUrl: string, - apiVersion: string, - modelActionPath: string, - apiKey: string, - search: string, -): string { - const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); - const normalizedAction = modelActionPath.replace(/^\/+/, ''); - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); - params.set('key', apiKey); - const query = params.toString(); - return `${base}/${normalizedAction}${query ? `?${query}` : ''}`; -} - export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string { return asTrimmedString(params?.geminiApiVersion) || 'v1beta'; } diff --git a/src/server/transformers/gemini/generate-content/outbound.ts b/src/server/transformers/gemini/generate-content/outbound.ts index 2a9da9f..f692ab8 100644 --- a/src/server/transformers/gemini/generate-content/outbound.ts +++ b/src/server/transformers/gemini/generate-content/outbound.ts @@ -7,45 +7,11 @@ import { toTransformerMetadataRecord, type TransformerMetadata, } from '../../shared/normalized.js'; - -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function baseIncludesVersion(baseUrl: string): boolean { - return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); -} - -function resolveBaseUrl(baseUrl: string, apiVersion: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (baseIncludesVersion(normalized)) return normalized; - return `${normalized}/${apiVersion}`; -} - -function resolveModelsUrl( - baseUrl: string, - apiVersion: string, - apiKey: string, -): string { - const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion); - const separator = resolvedBaseUrl.includes('?') ? '&' : '?'; - return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`; -} - -function resolveActionUrl( - baseUrl: string, - apiVersion: string, - modelActionPath: string, - apiKey: string, - search: string, -): string { - const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion); - const normalizedAction = modelActionPath.replace(/^\/+/, ''); - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); - params.set('key', apiKey); - const query = params.toString(); - return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`; -} +import { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; type GeminiRecord = Record; @@ -304,9 +270,9 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno } export const geminiGenerateContentOutbound = { - resolveBaseUrl, - resolveModelsUrl, - resolveActionUrl, + resolveBaseUrl: resolveGeminiNativeBaseUrl, + resolveModelsUrl: resolveGeminiModelsUrl, + resolveActionUrl: resolveGeminiGenerateContentUrl, extractTransformerMetadata, extractResponseMetadata, serializeAggregateResponse: serializeGeminiAggregateResponse, diff --git a/src/server/transformers/gemini/generate-content/urlResolver.ts b/src/server/transformers/gemini/generate-content/urlResolver.ts new file mode 100644 index 0000000..f48c2f7 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/urlResolver.ts @@ -0,0 +1,38 @@ +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function baseIncludesVersion(baseUrl: string): boolean { + return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); +} + +export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { + const normalized = normalizeBaseUrl(baseUrl); + if (baseIncludesVersion(normalized)) return normalized; + return `${normalized}/${apiVersion}`; +} + +export function resolveGeminiModelsUrl( + baseUrl: string, + apiVersion: string, + apiKey: string, +): string { + const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); + const separator = base.includes('?') ? '&' : '?'; + return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`; +} + +export function resolveGeminiGenerateContentUrl( + baseUrl: string, + apiVersion: string, + modelActionPath: string, + apiKey: string, + search: string, +): string { + const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + params.set('key', apiKey); + const query = params.toString(); + return `${base}/${normalizedAction}${query ? `?${query}` : ''}`; +} diff --git a/src/shared/conversationFileTypes.d.ts b/src/shared/conversationFileTypes.d.ts new file mode 100644 index 0000000..58e4941 --- /dev/null +++ b/src/shared/conversationFileTypes.d.ts @@ -0,0 +1,16 @@ +export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown'; + +export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[]; +export declare function classifyConversationFileMimeType( + mimeType: string | null | undefined, +): Exclude; +export declare function detectConversationFileKind(file: { + filename?: string | null; + mimeType?: string | null; +}): ConversationFileKind; +export declare function isSupportedConversationFileMimeType(mimeType: string): boolean; +export declare function buildConversationAcceptList(input: { + document: boolean; + image: boolean; + audio: boolean; +}): string; diff --git a/src/shared/conversationFileTypes.js b/src/shared/conversationFileTypes.js new file mode 100644 index 0000000..8f44065 --- /dev/null +++ b/src/shared/conversationFileTypes.js @@ -0,0 +1,52 @@ +export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json']; +const DOCUMENT_MIME_TYPES = new Set([ + 'application/json', + 'application/pdf', + 'text/markdown', + 'text/plain', +]); +const DOCUMENT_EXTENSIONS = ['.json', '.md', '.markdown', '.pdf', '.txt']; +const IMAGE_EXTENSIONS = ['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']; +const AUDIO_EXTENSIONS = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav', '.weba']; + +function normalizeValue(value) { + return (value || '').trim().toLowerCase(); +} + +export function classifyConversationFileMimeType(mimeType) { + const normalized = normalizeValue(mimeType); + if (normalized.startsWith('image/')) return 'image'; + if (normalized.startsWith('audio/')) return 'audio'; + return 'document'; +} + +export function detectConversationFileKind(file) { + const mimeType = normalizeValue(file?.mimeType); + if (mimeType) { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('audio/')) return 'audio'; + if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document'; + } + + const filename = normalizeValue(file?.filename); + if (!filename) return 'unknown'; + if (DOCUMENT_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'document'; + if (IMAGE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'image'; + if (AUDIO_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'audio'; + return 'unknown'; +} + +export function isSupportedConversationFileMimeType(mimeType) { + const normalized = normalizeValue(mimeType); + return DOCUMENT_MIME_TYPES.has(normalized) + || normalized.startsWith('image/') + || normalized.startsWith('audio/'); +} + +export function buildConversationAcceptList(input) { + const parts = []; + if (input.document) parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS); + if (input.image) parts.push('image/*'); + if (input.audio) parts.push('audio/*'); + return parts.join(','); +} diff --git a/src/shared/conversationFileTypes.test.ts b/src/shared/conversationFileTypes.test.ts new file mode 100644 index 0000000..2bfc229 --- /dev/null +++ b/src/shared/conversationFileTypes.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildConversationAcceptList, + classifyConversationFileMimeType, + detectConversationFileKind, + isSupportedConversationFileMimeType, +} from './conversationFileTypes.js'; + +describe('conversationFileTypes', () => { + it('classifies conversation file kinds from mime type and filename', () => { + expect(classifyConversationFileMimeType('image/png')).toBe('image'); + expect(classifyConversationFileMimeType('audio/mpeg')).toBe('audio'); + expect(classifyConversationFileMimeType('application/pdf')).toBe('document'); + + expect(detectConversationFileKind({ filename: 'paper.pdf', mimeType: null })).toBe('document'); + expect(detectConversationFileKind({ filename: 'photo.webp', mimeType: null })).toBe('image'); + expect(detectConversationFileKind({ filename: 'voice.mp3', mimeType: null })).toBe('audio'); + expect(detectConversationFileKind({ filename: 'unknown.bin', mimeType: null })).toBe('unknown'); + }); + + it('keeps supported mime and accept list rules in one place', () => { + expect(isSupportedConversationFileMimeType('application/pdf')).toBe(true); + expect(isSupportedConversationFileMimeType('image/jpeg')).toBe(true); + expect(isSupportedConversationFileMimeType('audio/wav')).toBe(true); + expect(isSupportedConversationFileMimeType('application/octet-stream')).toBe(false); + + expect(buildConversationAcceptList({ + document: true, + image: true, + audio: false, + })).toBe('.pdf,.txt,.md,.markdown,.json,image/*'); + }); +}); diff --git a/src/shared/platformIdentity.d.ts b/src/shared/platformIdentity.d.ts new file mode 100644 index 0000000..d6b308c --- /dev/null +++ b/src/shared/platformIdentity.d.ts @@ -0,0 +1,3 @@ +export declare const PLATFORM_ALIASES: Record; +export declare function normalizePlatformAlias(platform: unknown): string; +export declare function detectPlatformByUrlHint(url: string): string | undefined; diff --git a/src/shared/platformIdentity.js b/src/shared/platformIdentity.js new file mode 100644 index 0000000..f4fba11 --- /dev/null +++ b/src/shared/platformIdentity.js @@ -0,0 +1,65 @@ +export const PLATFORM_ALIASES = { + anyrouter: 'anyrouter', + 'wong-gongyi': 'new-api', + 'vo-api': 'new-api', + 'super-api': 'new-api', + 'rix-api': 'new-api', + 'neo-api': 'new-api', + newapi: 'new-api', + 'new api': 'new-api', + 'new-api': 'new-api', + oneapi: 'one-api', + 'one api': 'one-api', + 'one-api': 'one-api', + onehub: 'one-hub', + 'one-hub': 'one-hub', + donehub: 'done-hub', + 'done-hub': 'done-hub', + veloera: 'veloera', + sub2api: 'sub2api', + openai: 'openai', + codex: 'codex', + 'chatgpt-codex': 'codex', + 'chatgpt codex': 'codex', + anthropic: 'claude', + claude: 'claude', + gemini: 'gemini', + 'gemini-cli': 'gemini-cli', + antigravity: 'antigravity', + 'anti-gravity': 'antigravity', + google: 'gemini', + cliproxyapi: 'cliproxyapi', + cpa: 'cliproxyapi', + 'cli-proxy-api': 'cliproxyapi', +}; + +export function normalizePlatformAlias(platform) { + const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : ''; + if (!raw) return ''; + return PLATFORM_ALIASES[raw] ?? raw; +} + +export function detectPlatformByUrlHint(url) { + const normalized = (url || '').trim().toLowerCase(); + if (!normalized) return undefined; + + if (normalized.includes('api.openai.com')) return 'openai'; + if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; + if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; + if ( + normalized.includes('generativelanguage.googleapis.com') + || normalized.includes('googleapis.com/v1beta/openai') + || normalized.includes('gemini.google.com') + ) { + return 'gemini'; + } + if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; + if (normalized.includes('anyrouter')) return 'anyrouter'; + if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; + if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; + if (normalized.includes('veloera')) return 'veloera'; + if (normalized.includes('sub2api')) return 'sub2api'; + if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; + + return undefined; +} diff --git a/src/shared/platformIdentity.test.ts b/src/shared/platformIdentity.test.ts new file mode 100644 index 0000000..2cdc904 --- /dev/null +++ b/src/shared/platformIdentity.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { + detectPlatformByUrlHint, + normalizePlatformAlias, +} from './platformIdentity.js'; + +describe('platformIdentity', () => { + it('normalizes shared platform aliases', () => { + expect(normalizePlatformAlias('chatgpt-codex')).toBe('codex'); + expect(normalizePlatformAlias('anti-gravity')).toBe('antigravity'); + expect(normalizePlatformAlias('one api')).toBe('one-api'); + expect(normalizePlatformAlias('')).toBe(''); + }); + + it('detects platform by well-known url hints', () => { + expect(detectPlatformByUrlHint('https://api.openai.com/v1/models')).toBe('openai'); + expect(detectPlatformByUrlHint('https://chatgpt.com/backend-api/codex')).toBe('codex'); + expect(detectPlatformByUrlHint('https://api.anthropic.com/v1/messages')).toBe('claude'); + expect(detectPlatformByUrlHint('https://generativelanguage.googleapis.com/v1beta')).toBe('gemini'); + expect(detectPlatformByUrlHint('https://cloudcode-pa.googleapis.com')).toBe('gemini-cli'); + expect(detectPlatformByUrlHint('http://127.0.0.1:8317/v1/models')).toBe('cliproxyapi'); + }); +}); diff --git a/src/shared/proxyLogMeta.d.ts b/src/shared/proxyLogMeta.d.ts new file mode 100644 index 0000000..0e46208 --- /dev/null +++ b/src/shared/proxyLogMeta.d.ts @@ -0,0 +1,9 @@ +export type ParsedProxyLogMetadata = { + clientKind: string | null; + sessionId: string | null; + downstreamPath: string | null; + upstreamPath: string | null; + messageText: string; +}; + +export declare function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata; diff --git a/src/shared/proxyLogMeta.js b/src/shared/proxyLogMeta.js new file mode 100644 index 0000000..902b745 --- /dev/null +++ b/src/shared/proxyLogMeta.js @@ -0,0 +1,18 @@ +export function parseProxyLogMetadata(rawMessage) { + const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i); + const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i); + const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i); + const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i); + const messageText = rawMessage.replace( + /^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i, + '', + ).trim(); + + return { + clientKind: clientMatch?.[1]?.trim() || null, + sessionId: sessionMatch?.[1]?.trim() || null, + downstreamPath: downstreamMatch?.[1]?.trim() || null, + upstreamPath: upstreamMatch?.[1]?.trim() || null, + messageText, + }; +} diff --git a/src/shared/proxyLogMeta.test.ts b/src/shared/proxyLogMeta.test.ts new file mode 100644 index 0000000..1e77042 --- /dev/null +++ b/src/shared/proxyLogMeta.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { parseProxyLogMetadata } from './proxyLogMeta.js'; + +describe('proxyLogMeta', () => { + it('parses all supported proxy log prefixes', () => { + expect(parseProxyLogMetadata( + '[client:codex] [session:turn-1] [downstream:/v1/responses] [upstream:/responses] boom', + )).toEqual({ + clientKind: 'codex', + sessionId: 'turn-1', + downstreamPath: '/v1/responses', + upstreamPath: '/responses', + messageText: 'boom', + }); + }); + + it('keeps plain message text when no metadata prefixes exist', () => { + expect(parseProxyLogMetadata('network timeout')).toEqual({ + clientKind: null, + sessionId: null, + downstreamPath: null, + upstreamPath: null, + messageText: 'network timeout', + }); + }); +}); diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index c48a670..09fcbb2 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -19,9 +19,9 @@ import { clearAppInstallationState } from '../appLocalState.js'; import { tr } from '../i18n.js'; import { isExactModelPattern, - parseBrandIconValue, + resolveRouteBrand, + resolveRouteIcon, resolveRouteTitle, - ROUTE_ICON_NONE_VALUE, } from './token-routes/utils.js'; import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js'; @@ -178,12 +178,6 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null return null; } -function resolveRouteBrandSource(route: RouteSelectorItem): string { - const title = resolveRouteTitle(route); - if (getBrand(title)) return title; - return route.modelPattern; -} - export default function Settings() { const navigate = useNavigate(); const isMobile = useIsMobile(); @@ -1907,9 +1901,9 @@ export default function Settings() {
没有匹配的群组
) : filteredGroupRouteOptions.map((route) => { const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id); - const explicitBrandIcon = parseBrandIconValue(route.displayIcon || ''); - const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE; - const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim(); + const routeTitle = resolveRouteTitle(route); + const routeIcon = resolveRouteIcon(route); + const routeBrand = resolveRouteBrand(route); return (
- {resolveRouteTitle(route)} + {routeTitle} {!route.enabled && ( mode !== 'unsupported'; -const normalizeName = (value: string | null | undefined): string => (value || '').trim().toLowerCase(); - -function detectKindFromFilename(filename: string): ConversationFileKind { - if (!filename) return 'unknown'; - if (DOCUMENT_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'document'; - if (IMAGE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'image'; - if (AUDIO_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'audio'; - return 'unknown'; -} - -function resolveConversationFileKind(file: ConversationFileDescriptor): ConversationFileKind { - const mimeType = normalizeName(file.mimeType); - if (mimeType.startsWith('image/')) return 'image'; - if (mimeType.startsWith('audio/')) return 'audio'; - if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document'; - return detectKindFromFilename(normalizeName(file.filename)); -} - function buildSupportedTypeLabel(capability: ConversationFileCapability): string { const labels: string[] = []; if (isSupportedMode(capability.documentMode)) labels.push('PDF / TXT / Markdown / JSON'); @@ -111,11 +84,11 @@ export function resolveConversationFileCapability( } export function buildConversationFileAccept(capability: ConversationFileCapability): string { - const parts: string[] = []; - if (isSupportedMode(capability.documentMode)) parts.push(...DOCUMENT_ACCEPT_PARTS); - if (isSupportedMode(capability.imageMode)) parts.push('image/*'); - if (isSupportedMode(capability.audioMode)) parts.push('audio/*'); - return parts.join(','); + return buildConversationAcceptList({ + document: isSupportedMode(capability.documentMode), + image: isSupportedMode(capability.imageMode), + audio: isSupportedMode(capability.audioMode), + }); } export function buildConversationFileHint(capability: ConversationFileCapability): string { @@ -147,7 +120,7 @@ export function isConversationUploadedFileSupported( capability: ConversationFileCapability, file: ConversationFileDescriptor, ): boolean { - const kind = resolveConversationFileKind(file); + const kind = detectConversationFileKind(file); if (kind === 'document') return isSupportedMode(capability.documentMode); if (kind === 'image') return isSupportedMode(capability.imageMode); if (kind === 'audio') return isSupportedMode(capability.audioMode); diff --git a/src/web/pages/helpers/navigationFocus.ts b/src/web/pages/helpers/navigationFocus.ts index 117e4ee..e61c0b8 100644 --- a/src/web/pages/helpers/navigationFocus.ts +++ b/src/web/pages/helpers/navigationFocus.ts @@ -1,3 +1,5 @@ +import { isTruthyFlag } from './accountConnection.js'; + const FOCUS_SITE_ID_KEY = 'focusSiteId'; const FOCUS_ANNOUNCEMENT_ID_KEY = 'focusAnnouncementId'; const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId'; @@ -10,12 +12,6 @@ function normalizePositiveId(input: unknown): number | null { return value; } -function isTruthyFlag(value: string | null): boolean { - if (!value) return false; - const normalized = value.trim().toLowerCase(); - return normalized === '1' || normalized === 'true' || normalized === 'yes'; -} - export function buildSiteFocusPath(siteId: number): string { const normalizedId = normalizePositiveId(siteId); if (!normalizedId) return '/sites'; diff --git a/src/web/pages/helpers/proxyLogPathMeta.ts b/src/web/pages/helpers/proxyLogPathMeta.ts index 1a7b1ae..22cd785 100644 --- a/src/web/pages/helpers/proxyLogPathMeta.ts +++ b/src/web/pages/helpers/proxyLogPathMeta.ts @@ -1,3 +1,5 @@ +import { parseProxyLogMetadata } from '../../../shared/proxyLogMeta.js'; + type ProxyLogPathMeta = { clientFamily: string | null; sessionId: string | null; @@ -8,20 +10,13 @@ type ProxyLogPathMeta = { export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta { const raw = typeof message === 'string' ? message.trim() : ''; - const clientMatch = raw.match(/\[client:([^\]]+)\]/i); - const sessionMatch = raw.match(/\[session:([^\]]+)\]/i); - const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i); - const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i); - const stripped = raw.replace( - /^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i, - '', - ).trim(); + const parsed = parseProxyLogMetadata(raw); return { - clientFamily: clientMatch?.[1]?.trim() || null, - sessionId: sessionMatch?.[1]?.trim() || null, - downstreamPath: downstreamMatch?.[1]?.trim() || null, - upstreamPath: upstreamMatch?.[1]?.trim() || null, - errorMessage: stripped, + clientFamily: parsed.clientKind, + sessionId: parsed.sessionId, + downstreamPath: parsed.downstreamPath, + upstreamPath: parsed.upstreamPath, + errorMessage: parsed.messageText, }; } From 5d78658af9a5b37acc87abba275b94bc3dc227b9 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 22 Mar 2026 23:41:45 +0800 Subject: [PATCH 04/24] fix: address review feedback and code scanning alerts --- ...6-03-22-single-source-consolidation-pr1.md | 18 +- .../db/proxyFileSchemaCompatibility.test.ts | 2 +- src/server/db/proxyFileSchemaCompatibility.ts | 2 +- .../cliProfiles/codexProfile.test.ts | 29 + .../proxy-core/cliProfiles/codexProfile.ts | 48 +- .../proxy-core/providers/headerUtils.test.ts | 2 +- .../proxy-core/providers/headerUtils.ts | 11 +- src/server/proxy-core/surfaces/chatSurface.ts | 2 +- .../proxy-core/surfaces/filesSurface.ts | 22 +- .../surfaces/openAiResponsesSurface.ts | 82 +-- .../proxy-core/surfaces/sharedSurface.ts | 10 +- src/server/routes/api/accountTokens.ts | 38 +- .../routes/proxy/downstreamClientContext.ts | 4 +- .../services/accountMutationWorkflow.ts | 10 + src/server/services/backupService.ts | 6 +- src/server/services/oauth/service.ts | 9 +- .../services/platforms/standardApiProvider.ts | 6 +- .../services/proxyInputFileResolver.test.ts | 42 ++ src/server/services/proxyInputFileResolver.ts | 36 +- src/server/services/tokenRouter.test.ts | 4 +- src/server/services/tokenRouter.ts | 2 +- .../gemini/generate-content/index.test.ts | 18 + .../gemini/generate-content/urlResolver.ts | 47 +- .../openai/responses/normalization.ts | 2 +- src/shared/conversationFileTypes.d.ts | 5 + src/shared/conversationFileTypes.js | 36 + src/shared/conversationFileTypes.test.ts | 11 + src/shared/platformIdentity.js | 63 +- src/shared/platformIdentity.test.ts | 1 + src/shared/tokenRouteContract.test.ts | 1 + src/shared/tokenRoutePatterns.d.ts | 5 +- src/shared/tokenRoutePatterns.js | 662 +++++++++++++----- src/shared/tokenRoutePatterns.test.ts | 12 +- src/web/api.ts | 7 +- .../pages/helpers/accountConnection.test.ts | 8 + src/web/pages/helpers/accountConnection.ts | 4 +- .../conversationFileCapabilities.test.ts | 4 + src/web/pages/token-routes/utils.ts | 2 +- 38 files changed, 929 insertions(+), 344 deletions(-) diff --git a/docs/plans/2026-03-22-single-source-consolidation-pr1.md b/docs/plans/2026-03-22-single-source-consolidation-pr1.md index 96bf4a8..14a101b 100644 --- a/docs/plans/2026-03-22-single-source-consolidation-pr1.md +++ b/docs/plans/2026-03-22-single-source-consolidation-pr1.md @@ -10,7 +10,7 @@ --- -### Task 1: Savepoint Commit +## Task 1: Savepoint Commit **Files:** - Commit: `src/server/config.ts` @@ -42,7 +42,7 @@ git cherry-pick Expected: first commit preserved inside PR1 branch before the larger consolidation commit. -### Task 2: Responses Single Source +## Task 2: Responses Single Source **Files:** - Create: `src/server/transformers/openai/responses/normalization.ts` @@ -63,7 +63,7 @@ Make `conversion.ts` and `compatibility.ts` import the shared helpers instead of Run: `npm test -- src/server/transformers/openai/responses/conversion.test.ts` Expected: PASS, including assertions that compatibility exports point at the shared implementation. -### Task 3: Shared Surface Orchestration +## Task 3: Shared Surface Orchestration **Files:** - Create: `src/server/proxy-core/surfaces/sharedSurface.ts` @@ -85,7 +85,7 @@ Make chat and responses surfaces consume the shared orchestration helpers instea Run: `npm test -- src/server/proxy-core/surfaces/sharedSurface.test.ts` Expected: PASS -### Task 4: Provider Header Builders +## Task 4: Provider Header Builders **Files:** - Create: `src/server/proxy-core/providers/headerUtils.ts` @@ -108,7 +108,7 @@ Make both provider profiles and `upstreamEndpoint.ts` call the shared header bui Run: `npm test -- src/server/proxy-core/providers/headerUtils.test.ts` Expected: PASS -### Task 5: Cross-Layer Token Route Contract +## Task 5: Cross-Layer Token Route Contract **Files:** - Create: `src/shared/tokenRouteContract.js` @@ -137,7 +137,7 @@ Make token router, route APIs, token-routes helpers, and settings consume the sh Run: `npm test -- src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.test.ts` Expected: PASS -### Task 6: Account Mutation Workflow +## Task 6: Account Mutation Workflow **Files:** - Create: `src/server/services/accountMutationWorkflow.ts` @@ -158,7 +158,7 @@ Make account and account-token APIs call the workflow service instead of each ow Run: `npm test -- src/server/services/accountMutationWorkflow.test.ts` Expected: PASS -### Task 7: Standard API Provider Base +## Task 7: Standard API Provider Base **Files:** - Create: `src/server/services/platforms/standardApiProvider.ts` @@ -182,7 +182,7 @@ Make OpenAI, Claude, Gemini, and CLIProxyAPI adapters extend the shared base ins Run: `npm test -- src/server/services/platforms/standardApiProvider.test.ts src/server/services/platforms/llmUpstream.test.ts` Expected: PASS -### Task 8: Shared Input File Resolution And Web Helpers +## Task 8: Shared Input File Resolution And Web Helpers **Files:** - Modify: `src/server/services/proxyInputFileResolver.ts` @@ -212,7 +212,7 @@ Make `testProxy` / `proxyTest` and stream variants reuse the same implementation Run: `npm test -- src/server/services/proxyInputFileResolver.test.ts src/web/pages/helpers/accountConnection.test.ts src/web/api.test.ts` Expected: PASS -### Task 9: PR1 Verification And Commit +## Task 9: PR1 Verification And Commit **Files:** - Verify: `scripts/dev/copy-runtime-db-generated.ts` diff --git a/src/server/db/proxyFileSchemaCompatibility.test.ts b/src/server/db/proxyFileSchemaCompatibility.test.ts index ea08615..13b448b 100644 --- a/src/server/db/proxyFileSchemaCompatibility.test.ts +++ b/src/server/db/proxyFileSchemaCompatibility.test.ts @@ -71,7 +71,7 @@ describe('ensureProxyFileSchemaCompatibility', () => { 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', ); expect(executedSql).toContain( - 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`)', + 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))', ); }); diff --git a/src/server/db/proxyFileSchemaCompatibility.ts b/src/server/db/proxyFileSchemaCompatibility.ts index 54cd832..98f5f00 100644 --- a/src/server/db/proxyFileSchemaCompatibility.ts +++ b/src/server/db/proxyFileSchemaCompatibility.ts @@ -128,7 +128,7 @@ const CREATE_INDEX_SQL: Record = { ], mysql: [ 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', - 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`)', + 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))', ], }; diff --git a/src/server/proxy-core/cliProfiles/codexProfile.test.ts b/src/server/proxy-core/cliProfiles/codexProfile.test.ts index a621b3c..f8d2b75 100644 --- a/src/server/proxy-core/cliProfiles/codexProfile.test.ts +++ b/src/server/proxy-core/cliProfiles/codexProfile.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest'; import { detectCodexOfficialClientApp } from './codexProfile.js'; describe('detectCodexOfficialClientApp', () => { + it('returns null for missing or empty headers', () => { + expect(detectCodexOfficialClientApp()).toBeNull(); + expect(detectCodexOfficialClientApp({})).toBeNull(); + }); + it('detects official Codex clients from originator prefixes', () => { expect(detectCodexOfficialClientApp({ originator: 'codex_exec', @@ -20,6 +25,30 @@ describe('detectCodexOfficialClientApp', () => { }); }); + it('matches headers case-insensitively and from header arrays', () => { + expect(detectCodexOfficialClientApp({ + originator: 'CODEX_EXEC', + })).toEqual({ + clientAppId: 'codex_exec', + clientAppName: 'Codex Exec', + }); + expect(detectCodexOfficialClientApp({ + 'user-agent': 'Mozilla/5.0 CODEX_chatgpt_desktop/1.2', + })).toEqual({ + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + }); + expect(detectCodexOfficialClientApp({ + 'user-agent': ['Mozilla/5.0', 'codex_chatgpt_desktop/1.2'], + })).toEqual({ + clientAppId: 'codex_chatgpt_desktop', + clientAppName: 'Codex Desktop', + }); + expect(detectCodexOfficialClientApp({ + originator: ['ignored', 'other-client'], + })).toBeNull(); + }); + it('returns null for non-official Codex clients', () => { expect(detectCodexOfficialClientApp({ 'user-agent': 'OpenClaw/1.0', diff --git a/src/server/proxy-core/cliProfiles/codexProfile.ts b/src/server/proxy-core/cliProfiles/codexProfile.ts index 4b3dbf0..8c6fe62 100644 --- a/src/server/proxy-core/cliProfiles/codexProfile.ts +++ b/src/server/proxy-core/cliProfiles/codexProfile.ts @@ -66,33 +66,43 @@ const CODEX_OFFICIAL_CLIENT_APP_RULES = [ }, ] as const; -function headerValueToString(value: unknown): string | null { +function headerValueToStrings(value: unknown): string[] { if (typeof value === 'string') { const trimmed = value.trim(); - return trimmed || null; + return trimmed ? [trimmed] : []; } if (Array.isArray(value)) { + const values: string[] = []; for (const item of value) { if (typeof item !== 'string') continue; const trimmed = item.trim(); - if (trimmed) return trimmed; + if (trimmed) values.push(trimmed); } + return values; } - return null; + return []; +} + +function headerValueToString(value: unknown): string | null { + return headerValueToStrings(value)[0] || null; } function getHeaderValue(headers: Record | undefined, targetKey: string): string | null { - if (!headers) return null; + return getHeaderValues(headers, targetKey)[0] || null; +} + +function getHeaderValues(headers: Record | undefined, targetKey: string): string[] { + if (!headers) return []; const normalizedTarget = targetKey.trim().toLowerCase(); + const values: string[] = []; for (const [rawKey, rawValue] of Object.entries(headers)) { if (rawKey.trim().toLowerCase() !== normalizedTarget) continue; - return headerValueToString(rawValue); + values.push(...headerValueToStrings(rawValue)); } - - return null; + return values; } function hasHeaderPrefix(headers: Record | undefined, prefix: string): boolean { @@ -104,16 +114,18 @@ function hasHeaderPrefix(headers: Record | undefined, prefix: s }); } -function matchesHeaderPrefixes(value: string | null, prefixes: string[]): boolean { - const normalizedValue = value?.trim().toLowerCase() || ''; - if (!normalizedValue) return false; +function matchesHeaderPrefixes(value: string | string[] | null, prefixes: readonly string[]): boolean { + const values = Array.isArray(value) + ? value.map((item) => item.trim().toLowerCase()).filter(Boolean) + : [value?.trim().toLowerCase() || ''].filter(Boolean); + if (values.length === 0) return false; - return prefixes.some((prefix) => { + return values.some((normalizedValue) => prefixes.some((prefix) => { const normalizedPrefix = prefix.trim().toLowerCase(); if (!normalizedPrefix) return false; return normalizedValue.startsWith(normalizedPrefix) || normalizedValue.includes(normalizedPrefix); - }); + })); } function isCodexPath(path: string): boolean { @@ -127,10 +139,8 @@ export function detectCodexOfficialClientApp( headers?: Record, ): CodexOfficialClientApp | null { for (const rule of CODEX_OFFICIAL_CLIENT_APP_RULES) { - const matchesOriginator = Array.isArray(rule.originatorPrefixes) - && matchesHeaderPrefixes(getHeaderValue(headers, 'originator'), [...rule.originatorPrefixes]); - const matchesUserAgent = Array.isArray(rule.userAgentPrefixes) - && matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), [...rule.userAgentPrefixes]); + const matchesOriginator = matchesHeaderPrefixes(getHeaderValues(headers, 'originator'), rule.originatorPrefixes); + const matchesUserAgent = matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), rule.userAgentPrefixes); if (!matchesOriginator && !matchesUserAgent) continue; return { clientAppId: rule.id, @@ -156,9 +166,9 @@ export function isCodexRequest(input: DetectCliProfileInput): boolean { const headers = input.headers; if (!headers) return false; - const originator = getHeaderValue(headers, 'originator'); + const originator = getHeaderValues(headers, 'originator'); if (matchesHeaderPrefixes(originator, CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES)) return true; - if (matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true; + if (matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true; if (getHeaderValue(headers, 'openai-beta')) return true; if (hasHeaderPrefix(headers, 'x-stainless-')) return true; if (getCodexSessionId(headers)) return true; diff --git a/src/server/proxy-core/providers/headerUtils.test.ts b/src/server/proxy-core/providers/headerUtils.test.ts index bc79c84..a762d3a 100644 --- a/src/server/proxy-core/providers/headerUtils.test.ts +++ b/src/server/proxy-core/providers/headerUtils.test.ts @@ -101,7 +101,7 @@ describe('provider header utils', () => { const headers = buildGeminiCliRuntimeHeaders({ baseHeaders: { - Authorization: 'Bearer test', + authorization: 'Bearer test', }, providerHeaders: { 'x-goog-api-client': 'gl-node/22 gccl/0.31.0', diff --git a/src/server/proxy-core/providers/headerUtils.ts b/src/server/proxy-core/providers/headerUtils.ts index 3b1ac8a..f31600e 100644 --- a/src/server/proxy-core/providers/headerUtils.ts +++ b/src/server/proxy-core/providers/headerUtils.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { pbkdf2Sync, randomUUID } from 'node:crypto'; const CODEX_CLIENT_VERSION = '0.101.0'; const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; @@ -33,8 +33,8 @@ export function getInputHeader( } export function uuidFromSeed(seed: string): string { - const hash = createHash('sha1').update(seed).digest(); - const bytes = new Uint8Array(hash.subarray(0, 16)); + const derived = pbkdf2Sync(seed, 'metapi-runtime-header-seed', 10_000, 16, 'sha256'); + const bytes = new Uint8Array(derived); bytes[6] = (bytes[6]! & 0x0f) | 0x50; bytes[8] = (bytes[8]! & 0x3f) | 0x80; const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); @@ -153,7 +153,7 @@ export function buildCodexRuntimeHeaders(input: { ); return { - Authorization: authorization, + ...(authorization ? { Authorization: authorization } : {}), 'Content-Type': 'application/json', ...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}), Originator: originator, @@ -221,6 +221,7 @@ export function buildGeminiCliRuntimeHeaders(input: { modelName: string; stream: boolean; }): Record { + const authorization = getInputHeader(input.baseHeaders, 'authorization'); const apiClient = ( getInputHeader(input.providerHeaders, 'x-goog-api-client') || getInputHeader(input.baseHeaders, 'x-goog-api-client') @@ -231,7 +232,7 @@ export function buildGeminiCliRuntimeHeaders(input: { ); const headers: Record = { - Authorization: input.baseHeaders.Authorization, + ...(authorization ? { Authorization: authorization } : {}), 'Content-Type': 'application/json', 'User-Agent': userAgent, }; diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 7b96d8a..2e0d0e3 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -808,7 +808,7 @@ export async function handleClaudeCountTokensSurfaceRequest( retryCount += 1; continue; } - return reply.code(failureOutcome.status).type(contentType).send(payload); + return reply.code(failureOutcome.status).send(failureOutcome.payload); } tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName); diff --git a/src/server/proxy-core/surfaces/filesSurface.ts b/src/server/proxy-core/surfaces/filesSurface.ts index 3194e25..456f002 100644 --- a/src/server/proxy-core/surfaces/filesSurface.ts +++ b/src/server/proxy-core/surfaces/filesSurface.ts @@ -8,7 +8,10 @@ import { softDeleteProxyFileByPublicIdForOwner, } from '../../services/proxyFileStore.js'; import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js'; -import { isSupportedConversationFileMimeType } from '../../../shared/conversationFileTypes.js'; +import { + isSupportedConversationFileMimeType, + resolveConversationFileMimeType, +} from '../../../shared/conversationFileTypes.js'; function invalidRequest(reply: FastifyReply, message: string) { return reply.code(400).send({ error: { message, type: 'invalid_request_error' } }); @@ -44,21 +47,6 @@ function toFileObject(record: { }; } -function inferMimeTypeFromFilename(filename: string): string { - const normalized = filename.trim().toLowerCase(); - if (normalized.endsWith('.pdf')) return 'application/pdf'; - if (normalized.endsWith('.txt')) return 'text/plain'; - if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown'; - if (normalized.endsWith('.json')) return 'application/json'; - if (normalized.endsWith('.png')) return 'image/png'; - if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'; - if (normalized.endsWith('.gif')) return 'image/gif'; - if (normalized.endsWith('.webp')) return 'image/webp'; - if (normalized.endsWith('.wav')) return 'audio/wav'; - if (normalized.endsWith('.mp3')) return 'audio/mpeg'; - return 'application/octet-stream'; -} - export async function filesProxyRoute(app: FastifyInstance) { ensureMultipartBufferParser(app); @@ -79,7 +67,7 @@ export async function filesProxyRoute(app: FastifyInstance) { } const filename = fileEntry.name || 'upload.bin'; - const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase(); + const mimeType = resolveConversationFileMimeType(fileEntry.type, filename); if (!isSupportedConversationFileMimeType(mimeType)) { return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`); } diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index c14e164..49e7eb2 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -715,46 +715,50 @@ export async function handleOpenAiResponsesSurfaceRequest( usage: parsedUsage, serializationMode: isCompactRequest ? 'compact' : 'response', }); - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); + try { + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: selected.site, + account: selected.account, + tokenValue: selected.tokenValue, + tokenName: selected.tokenName, + modelName: selected.actualModel || requestedModel, + requestStartedAtMs: startTime, + requestEndedAtMs: startTime + latency, + localLatencyMs: latency, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ + site: selected.site, + account: selected.account, + modelName: selected.actualModel || requestedModel, + parsedUsage, + resolvedUsage, + }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); - recordDownstreamCostUsage(request, estimatedCost); - await failureToolkit.log({ - selected, - modelRequested: requestedModel, - status: 'success', - httpStatus: 200, - latencyMs: latency, - errorMessage: null, - retryCount, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - estimatedCost, - billingDetails, - upstreamPath: successfulUpstreamPath, - }); + tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); + recordDownstreamCostUsage(request, estimatedCost); + await failureToolkit.log({ + selected, + modelRequested: requestedModel, + status: 'success', + httpStatus: 200, + latencyMs: latency, + errorMessage: null, + retryCount, + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, + estimatedCost, + billingDetails, + upstreamPath: successfulUpstreamPath, + }); + } catch (error) { + console.error('[responses] post-response bookkeeping failed:', error); + } return reply.send(downstreamData); } catch (err: any) { const failureOutcome = await failureToolkit.handleExecutionError({ diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index 31baffd..8e4c559 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -206,7 +206,7 @@ export function createSurfaceFailureToolkit(input: { retryCount: number; }): Promise { const rawErrText = args.rawErrText || args.errText; - tokenRouter.recordFailure(args.selected.channel.id, { + await tokenRouter.recordFailure(args.selected.channel.id, { status: args.status, errorText: rawErrText, modelName: args.modelName, @@ -269,7 +269,7 @@ export function createSurfaceFailureToolkit(input: { totalTokens?: number; upstreamPath?: string | null; }): Promise { - tokenRouter.recordFailure(args.selected.channel.id, { + await tokenRouter.recordFailure(args.selected.channel.id, { status: args.failure.status, errorText: args.failure.reason, modelName: args.modelName, @@ -318,7 +318,7 @@ export function createSurfaceFailureToolkit(input: { latencyMs: number; retryCount: number; }): Promise { - tokenRouter.recordFailure(args.selected.channel.id, { + await tokenRouter.recordFailure(args.selected.channel.id, { errorText: args.errorMessage, modelName: args.modelName, }); @@ -368,13 +368,13 @@ export function createSurfaceFailureToolkit(input: { }) { const errorMessage = args.errorMessage || 'stream processing failed'; if (typeof args.runtimeFailureStatus === 'number') { - tokenRouter.recordFailure(args.selected.channel.id, { + await tokenRouter.recordFailure(args.selected.channel.id, { status: args.runtimeFailureStatus, errorText: errorMessage, modelName: args.modelName, }); } else { - tokenRouter.recordFailure(args.selected.channel.id, args.modelName); + await tokenRouter.recordFailure(args.selected.channel.id, args.modelName); } await log({ selected: args.selected, diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 548a3d1..1f981fd 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -228,11 +228,39 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise ensureDefaultTokenForAccount( + input.accountId, + input.preferredApiToken!, + { name: 'default', source: input.defaultTokenSource || 'manual' }, + )); + if (defaultTokenId != null) { + result.defaultTokenId = defaultTokenId; + } + } } else if (!input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) { const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount( input.accountId, diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 93227db..3badddc 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -617,7 +617,11 @@ function normalizeOriginUrl(raw: string): string { function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string { const rawPlatformText = asString(rawPlatform).toLowerCase(); const normalizedPlatform = rawPlatformText - ? (PLATFORM_ALIASES[rawPlatformText] ?? (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '')) + ? ( + Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, rawPlatformText) + ? PLATFORM_ALIASES[rawPlatformText] + : (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '') + ) : ''; if (normalizedPlatform) return normalizedPlatform; diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts index eacf899..daf22b4 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -516,15 +516,16 @@ export async function listOauthConnections(options: { routeChannelCountByAccount.set(row.accountId, row.count ?? 0); } - const items = rows.map((row) => { - const oauth = getOauthInfoFromAccount(row.accounts)!; + const items = rows.flatMap((row) => { + const oauth = getOauthInfoFromAccount(row.accounts); + if (!oauth) return []; const models = modelMap.get(row.accounts.id) || []; const status = ( oauth.modelDiscoveryStatus === 'abnormal' || row.accounts.status !== 'active' || row.sites.status !== 'active' ) ? 'abnormal' : 'healthy'; - return { + return [{ accountId: row.accounts.id, siteId: row.sites.id, provider: oauth.provider, @@ -546,7 +547,7 @@ export async function listOauthConnections(options: { url: row.sites.url, platform: row.sites.platform, }, - }; + }]; }); return { items, total, limit, offset }; diff --git a/src/server/services/platforms/standardApiProvider.ts b/src/server/services/platforms/standardApiProvider.ts index 59156f9..1448168 100644 --- a/src/server/services/platforms/standardApiProvider.ts +++ b/src/server/services/platforms/standardApiProvider.ts @@ -13,7 +13,11 @@ type FetchModelsOptions = { }; export function normalizePlatformBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); + let normalized = baseUrl || ''; + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; } export function resolveVersionedModelsUrl(baseUrl: string): string { diff --git a/src/server/services/proxyInputFileResolver.test.ts b/src/server/services/proxyInputFileResolver.test.ts index 0ca9833..10b4c3b 100644 --- a/src/server/services/proxyInputFileResolver.test.ts +++ b/src/server/services/proxyInputFileResolver.test.ts @@ -123,4 +123,46 @@ describe('proxyInputFileResolver', () => { ], }); }); + + it('falls back from application/octet-stream to filename-based mime inference', async () => { + const { inlineLocalInputFileReferences } = await import('./proxyInputFileResolver.js'); + await expect(inlineLocalInputFileReferences( + { + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { + type: 'file', + file: { + filename: 'paper.pdf', + mime_type: 'application/octet-stream', + file_data: Buffer.from('%PDF-octet').toString('base64'), + }, + }, + ], + }, + ], + }, + { ownerType: 'managed_key', ownerId: '7' }, + )).resolves.toEqual({ + model: 'gpt-5', + messages: [ + { + role: 'user', + content: [ + { + type: 'file', + file: { + file_data: Buffer.from('%PDF-octet').toString('base64'), + filename: 'paper.pdf', + mime_type: 'application/pdf', + }, + }, + ], + }, + ], + }); + }); }); diff --git a/src/server/services/proxyInputFileResolver.ts b/src/server/services/proxyInputFileResolver.ts index d42d08a..80df2cf 100644 --- a/src/server/services/proxyInputFileResolver.ts +++ b/src/server/services/proxyInputFileResolver.ts @@ -1,10 +1,11 @@ import type { ProxyResourceOwner } from '../middleware/auth.js'; -import { getProxyFileByPublicIdForOwner } from './proxyFileStore.js'; +import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js'; import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js'; -import { isSupportedConversationFileMimeType } from '../../shared/conversationFileTypes.js'; - -const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-'; +import { + isSupportedConversationFileMimeType, + resolveConversationFileMimeType, +} from '../../shared/conversationFileTypes.js'; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); @@ -26,24 +27,6 @@ function cloneJsonValue(value: T): T { return value; } -function inferMimeTypeFromFilename(filename: string): string { - const normalized = filename.toLowerCase(); - if (normalized.endsWith('.pdf')) return 'application/pdf'; - if (normalized.endsWith('.txt')) return 'text/plain'; - if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown'; - if (normalized.endsWith('.json')) return 'application/json'; - if (normalized.endsWith('.png')) return 'image/png'; - if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'; - if (normalized.endsWith('.gif')) return 'image/gif'; - if (normalized.endsWith('.webp')) return 'image/webp'; - if (normalized.endsWith('.wav')) return 'audio/wav'; - if (normalized.endsWith('.mp3')) return 'audio/mpeg'; - if (normalized.endsWith('.m4a')) return 'audio/mp4'; - if (normalized.endsWith('.ogg')) return 'audio/ogg'; - if (normalized.endsWith('.webm')) return 'audio/webm'; - return 'application/octet-stream'; -} - function isSupportedMimeType(mimeType: string): boolean { return isSupportedConversationFileMimeType(mimeType); } @@ -116,7 +99,7 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc }> { if (fileLike.fileData) { const filename = fileLike.filename || 'upload.bin'; - const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename); + const mimeType = resolveConversationFileMimeType(fileLike.mimeType, filename); if (!isSupportedMimeType(mimeType)) { throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`); } @@ -133,7 +116,10 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc if (!stored) { throw new ProxyInputFileResolutionError(404, `file not found: ${fileLike.fileId}`, 'not_found_error'); } - const mimeType = fileLike.mimeType || stored.mimeType || inferMimeTypeFromFilename(stored.filename); + const mimeType = resolveConversationFileMimeType( + fileLike.mimeType || stored.mimeType, + stored.filename, + ); if (!isSupportedMimeType(mimeType)) { throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`); } @@ -145,7 +131,7 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc } const filename = fileLike.filename || 'upload.bin'; - const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename); + const mimeType = resolveConversationFileMimeType(fileLike.mimeType, filename); if (!fileLike.fileData) { throw new ProxyInputFileResolutionError(400, `file_data is required for inline file block: ${filename}`); } diff --git a/src/server/services/tokenRouter.test.ts b/src/server/services/tokenRouter.test.ts index 1784d9a..087630d 100644 --- a/src/server/services/tokenRouter.test.ts +++ b/src/server/services/tokenRouter.test.ts @@ -11,8 +11,8 @@ type Candidate = { describe('filterRecentlyFailedCandidates', () => { it('rejects unsafe nested-quantifier regex route patterns', () => { - expect(parseRegexModelPattern('re:^(a+)+$')).toBeNull(); - expect(matchesModelPattern('aaaa', 're:^(a+)+$')).toBe(false); + expect(parseRegexModelPattern('re:(?=claude)')).toBeNull(); + expect(matchesModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false); }); it('uses a short default recent-failure window', () => { diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 5a4c85a..17cc51b 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -894,7 +894,7 @@ export function isRegexModelPattern(pattern: string): boolean { return isTokenRouteRegexPattern(pattern); } -export function parseRegexModelPattern(pattern: string): RegExp | null { +export function parseRegexModelPattern(pattern: string): { test(value: string): boolean } | null { return parseTokenRouteRegexPattern(pattern).regex; } diff --git a/src/server/transformers/gemini/generate-content/index.test.ts b/src/server/transformers/gemini/generate-content/index.test.ts index c94d77e..83e2b77 100644 --- a/src/server/transformers/gemini/generate-content/index.test.ts +++ b/src/server/transformers/gemini/generate-content/index.test.ts @@ -27,6 +27,24 @@ describe('geminiGenerateContentTransformer.inbound', () => { expect(geminiGenerateContentOutbound.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl); }); + it('preserves base-url query params when resolving Gemini endpoints', () => { + expect( + resolveGeminiNativeBaseUrl('https://example.com/native?alt=sse', 'v1beta'), + ).toBe('https://example.com/native/v1beta?alt=sse'); + expect( + resolveGeminiModelsUrl('https://example.com/native?alt=sse', 'v1beta', 'api-key'), + ).toBe('https://example.com/native/v1beta/models?alt=sse&key=api-key'); + expect( + resolveGeminiGenerateContentUrl( + 'https://example.com/native?alt=sse', + 'v1beta', + '/models/gemini-2.5-pro:generateContent', + 'api-key', + '?trace=1', + ), + ).toBe('https://example.com/native/v1beta/models/gemini-2.5-pro:generateContent?alt=sse&trace=1&key=api-key'); + }); + it('parses native Gemini requests into canonical envelopes', () => { const result = geminiGenerateContentTransformer.parseRequest({ model: 'gemini-2.5-pro', diff --git a/src/server/transformers/gemini/generate-content/urlResolver.ts b/src/server/transformers/gemini/generate-content/urlResolver.ts index f48c2f7..9dca890 100644 --- a/src/server/transformers/gemini/generate-content/urlResolver.ts +++ b/src/server/transformers/gemini/generate-content/urlResolver.ts @@ -1,15 +1,33 @@ -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); +function normalizePathname(pathname: string): string { + let normalized = pathname || ''; + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; } -function baseIncludesVersion(baseUrl: string): boolean { - return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); +function splitBaseUrl(baseUrl: string): { path: string; query: string } { + const raw = baseUrl || ''; + const queryIndex = raw.indexOf('?'); + if (queryIndex < 0) { + return { path: normalizePathname(raw), query: '' }; + } + return { + path: normalizePathname(raw.slice(0, queryIndex)), + query: raw.slice(queryIndex + 1), + }; +} + +function baseIncludesVersion(path: string): boolean { + return /\/v\d+(?:beta)?(?:\/|$)/i.test(path); } export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (baseIncludesVersion(normalized)) return normalized; - return `${normalized}/${apiVersion}`; + const { path, query } = splitBaseUrl(baseUrl); + const normalizedPath = baseIncludesVersion(path) + ? path + : `${path}/${apiVersion.replace(/^\/+/, '')}`; + return `${normalizedPath}${query ? `?${query}` : ''}`; } export function resolveGeminiModelsUrl( @@ -18,8 +36,10 @@ export function resolveGeminiModelsUrl( apiKey: string, ): string { const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); - const separator = base.includes('?') ? '&' : '?'; - return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`; + const [path, query = ''] = base.split('?', 2); + const params = new URLSearchParams(query); + params.set('key', apiKey); + return `${path}/models?${params.toString()}`; } export function resolveGeminiGenerateContentUrl( @@ -31,8 +51,13 @@ export function resolveGeminiGenerateContentUrl( ): string { const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); const normalizedAction = modelActionPath.replace(/^\/+/, ''); - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + const [path, baseQuery = ''] = base.split('?', 2); + const params = new URLSearchParams(baseQuery); + const extraParams = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + for (const [key, value] of extraParams) { + params.set(key, value); + } params.set('key', apiKey); const query = params.toString(); - return `${base}/${normalizedAction}${query ? `?${query}` : ''}`; + return `${path}/${normalizedAction}${query ? `?${query}` : ''}`; } diff --git a/src/server/transformers/openai/responses/normalization.ts b/src/server/transformers/openai/responses/normalization.ts index bbbb667..d27cfb2 100644 --- a/src/server/transformers/openai/responses/normalization.ts +++ b/src/server/transformers/openai/responses/normalization.ts @@ -1,7 +1,7 @@ import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js'; function isRecord(value: unknown): value is Record { - return !!value && typeof value === 'object'; + return !!value && typeof value === 'object' && !Array.isArray(value); } function asTrimmedString(value: unknown): string { diff --git a/src/shared/conversationFileTypes.d.ts b/src/shared/conversationFileTypes.d.ts index 58e4941..9717c16 100644 --- a/src/shared/conversationFileTypes.d.ts +++ b/src/shared/conversationFileTypes.d.ts @@ -1,6 +1,11 @@ export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown'; export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[]; +export declare function inferConversationFileMimeType(filename: string | null | undefined): string; +export declare function resolveConversationFileMimeType( + mimeType: string | null | undefined, + filename: string | null | undefined, +): string; export declare function classifyConversationFileMimeType( mimeType: string | null | undefined, ): Exclude; diff --git a/src/shared/conversationFileTypes.js b/src/shared/conversationFileTypes.js index 8f44065..095cd6d 100644 --- a/src/shared/conversationFileTypes.js +++ b/src/shared/conversationFileTypes.js @@ -1,4 +1,8 @@ export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json']; +const GENERIC_MIME_TYPES = new Set([ + 'application/octet-stream', + 'binary/octet-stream', +]); const DOCUMENT_MIME_TYPES = new Set([ 'application/json', 'application/pdf', @@ -13,6 +17,37 @@ function normalizeValue(value) { return (value || '').trim().toLowerCase(); } +export function inferConversationFileMimeType(filename) { + const normalized = normalizeValue(filename); + if (normalized.endsWith('.pdf')) return 'application/pdf'; + if (normalized.endsWith('.txt')) return 'text/plain'; + if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown'; + if (normalized.endsWith('.json')) return 'application/json'; + if (normalized.endsWith('.png')) return 'image/png'; + if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg'; + if (normalized.endsWith('.gif')) return 'image/gif'; + if (normalized.endsWith('.webp')) return 'image/webp'; + if (normalized.endsWith('.svg')) return 'image/svg+xml'; + if (normalized.endsWith('.avif')) return 'image/avif'; + if (normalized.endsWith('.bmp')) return 'image/bmp'; + if (normalized.endsWith('.wav')) return 'audio/wav'; + if (normalized.endsWith('.mp3')) return 'audio/mpeg'; + if (normalized.endsWith('.m4a')) return 'audio/mp4'; + if (normalized.endsWith('.ogg')) return 'audio/ogg'; + if (normalized.endsWith('.aac')) return 'audio/aac'; + if (normalized.endsWith('.flac')) return 'audio/flac'; + if (normalized.endsWith('.weba')) return 'audio/webm'; + return 'application/octet-stream'; +} + +export function resolveConversationFileMimeType(mimeType, filename) { + const normalizedMimeType = normalizeValue(mimeType); + if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) { + return normalizedMimeType; + } + return inferConversationFileMimeType(filename); +} + export function classifyConversationFileMimeType(mimeType) { const normalized = normalizeValue(mimeType); if (normalized.startsWith('image/')) return 'image'; @@ -26,6 +61,7 @@ export function detectConversationFileKind(file) { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('audio/')) return 'audio'; if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document'; + if (!GENERIC_MIME_TYPES.has(mimeType)) return 'unknown'; } const filename = normalizeValue(file?.filename); diff --git a/src/shared/conversationFileTypes.test.ts b/src/shared/conversationFileTypes.test.ts index 2bfc229..740fc70 100644 --- a/src/shared/conversationFileTypes.test.ts +++ b/src/shared/conversationFileTypes.test.ts @@ -4,7 +4,9 @@ import { buildConversationAcceptList, classifyConversationFileMimeType, detectConversationFileKind, + inferConversationFileMimeType, isSupportedConversationFileMimeType, + resolveConversationFileMimeType, } from './conversationFileTypes.js'; describe('conversationFileTypes', () => { @@ -31,4 +33,13 @@ describe('conversationFileTypes', () => { audio: false, })).toBe('.pdf,.txt,.md,.markdown,.json,image/*'); }); + + it('falls back from generic mime types to filename-based inference consistently', () => { + expect(inferConversationFileMimeType('photo.avif')).toBe('image/avif'); + expect(resolveConversationFileMimeType('application/octet-stream', 'paper.pdf')).toBe('application/pdf'); + expect(detectConversationFileKind({ + filename: 'paper.pdf', + mimeType: 'application/octet-stream', + })).toBe('document'); + }); }); diff --git a/src/shared/platformIdentity.js b/src/shared/platformIdentity.js index f4fba11..dd5adb9 100644 --- a/src/shared/platformIdentity.js +++ b/src/shared/platformIdentity.js @@ -1,4 +1,4 @@ -export const PLATFORM_ALIASES = { +export const PLATFORM_ALIASES = Object.assign(Object.create(null), { anyrouter: 'anyrouter', 'wong-gongyi': 'new-api', 'vo-api': 'new-api', @@ -31,35 +31,64 @@ export const PLATFORM_ALIASES = { cliproxyapi: 'cliproxyapi', cpa: 'cliproxyapi', 'cli-proxy-api': 'cliproxyapi', -}; +}); + +function getPlatformAlias(raw) { + return Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, raw) + ? PLATFORM_ALIASES[raw] + : undefined; +} + +function normalizeUrlCandidate(url) { + return typeof url === 'string' ? url.trim() : ''; +} + +function parseUrlCandidate(url) { + const normalized = normalizeUrlCandidate(url); + if (!normalized) return null; + + const candidates = normalized.includes('://') + ? [normalized] + : [`https://${normalized}`]; + for (const candidate of candidates) { + try { + return new URL(candidate); + } catch {} + } + return null; +} export function normalizePlatformAlias(platform) { const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : ''; if (!raw) return ''; - return PLATFORM_ALIASES[raw] ?? raw; + return getPlatformAlias(raw) ?? raw; } export function detectPlatformByUrlHint(url) { - const normalized = (url || '').trim().toLowerCase(); + const normalized = normalizeUrlCandidate(url).toLowerCase(); if (!normalized) return undefined; + const parsed = parseUrlCandidate(normalized); + const host = parsed?.hostname?.trim().toLowerCase() || ''; + const port = parsed?.port?.trim() || ''; + const path = parsed?.pathname?.trim().toLowerCase() || ''; - if (normalized.includes('api.openai.com')) return 'openai'; - if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; - if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; + if (host === 'api.openai.com') return 'openai'; + if (host === 'chatgpt.com' && path.startsWith('/backend-api/codex')) return 'codex'; + if (host === 'api.anthropic.com' || (host === 'anthropic.com' && path.startsWith('/v1'))) return 'claude'; if ( - normalized.includes('generativelanguage.googleapis.com') - || normalized.includes('googleapis.com/v1beta/openai') - || normalized.includes('gemini.google.com') + host === 'generativelanguage.googleapis.com' + || host === 'gemini.google.com' + || ((host === 'googleapis.com' || host.endsWith('.googleapis.com')) && path.startsWith('/v1beta/openai')) ) { return 'gemini'; } - if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; - if (normalized.includes('anyrouter')) return 'anyrouter'; - if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; - if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; - if (normalized.includes('veloera')) return 'veloera'; - if (normalized.includes('sub2api')) return 'sub2api'; - if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; + if (host === 'cloudcode-pa.googleapis.com') return 'gemini-cli'; + if ((host === '127.0.0.1' || host === 'localhost') && port === '8317') return 'cliproxyapi'; + if (host.includes('anyrouter')) return 'anyrouter'; + if (host.includes('donehub') || host.includes('done-hub')) return 'done-hub'; + if (host.includes('onehub') || host.includes('one-hub')) return 'one-hub'; + if (host.includes('veloera')) return 'veloera'; + if (host.includes('sub2api')) return 'sub2api'; return undefined; } diff --git a/src/shared/platformIdentity.test.ts b/src/shared/platformIdentity.test.ts index 2cdc904..1244170 100644 --- a/src/shared/platformIdentity.test.ts +++ b/src/shared/platformIdentity.test.ts @@ -20,5 +20,6 @@ describe('platformIdentity', () => { expect(detectPlatformByUrlHint('https://generativelanguage.googleapis.com/v1beta')).toBe('gemini'); expect(detectPlatformByUrlHint('https://cloudcode-pa.googleapis.com')).toBe('gemini-cli'); expect(detectPlatformByUrlHint('http://127.0.0.1:8317/v1/models')).toBe('cliproxyapi'); + expect(detectPlatformByUrlHint('https://evil.example.com/?next=https://api.openai.com/v1/models')).toBeUndefined(); }); }); diff --git a/src/shared/tokenRouteContract.test.ts b/src/shared/tokenRouteContract.test.ts index 80ef1ad..d984ee5 100644 --- a/src/shared/tokenRouteContract.test.ts +++ b/src/shared/tokenRouteContract.test.ts @@ -8,5 +8,6 @@ describe('token route contract', () => { expect(normalizeTokenRouteMode('pattern')).toBe('pattern'); expect(normalizeTokenRouteMode('anything-else')).toBe('pattern'); expect(normalizeTokenRouteMode(null)).toBe('pattern'); + expect(normalizeTokenRouteMode(undefined)).toBe('pattern'); }); }); diff --git a/src/shared/tokenRoutePatterns.d.ts b/src/shared/tokenRoutePatterns.d.ts index 82c801e..3997026 100644 --- a/src/shared/tokenRoutePatterns.d.ts +++ b/src/shared/tokenRoutePatterns.d.ts @@ -1,7 +1,10 @@ +export type TokenRoutePatternMatcher = { + test(value: string): boolean; +}; export declare function isTokenRouteRegexPattern(pattern: string): boolean; export declare function isExactTokenRouteModelPattern(pattern: string): boolean; export declare function parseTokenRouteRegexPattern(pattern: string): { - regex: RegExp | null; + regex: TokenRoutePatternMatcher | null; error: string | null; }; export declare function matchesTokenRouteModelPattern(model: string, pattern: string): boolean; diff --git a/src/shared/tokenRoutePatterns.js b/src/shared/tokenRoutePatterns.js index 534fb0b..b35b1b1 100644 --- a/src/shared/tokenRoutePatterns.js +++ b/src/shared/tokenRoutePatterns.js @@ -1,190 +1,514 @@ +let nextNodeId = 1; + +function createNode(type, fields = {}) { + return { + id: nextNodeId++, + type, + ...fields, + }; +} + +function isDigitCharacter(ch) { + if (!ch) return false; + const code = ch.charCodeAt(0); + return code >= 48 && code <= 57; +} + function readRegexQuantifierLength(pattern, startIndex) { - const ch = pattern[startIndex]; - if (ch === '*' || ch === '+' || ch === '?') - return 1; - if (ch !== '{') - return 0; - let index = startIndex + 1; - let sawDigit = false; - while (index < pattern.length && /\d/.test(pattern[index])) { - sawDigit = true; - index += 1; + const ch = pattern[startIndex]; + if (ch === '*' || ch === '+' || ch === '?') return 1; + if (ch !== '{') return 0; + + let index = startIndex + 1; + let sawDigit = false; + while (index < pattern.length && isDigitCharacter(pattern[index])) { + sawDigit = true; + index += 1; + } + if (!sawDigit) return 0; + if (pattern[index] === ',') { + index += 1; + while (index < pattern.length && isDigitCharacter(pattern[index])) { + index += 1; } - if (!sawDigit) - return 0; - if (pattern[index] === ',') { - index += 1; - while (index < pattern.length && /\d/.test(pattern[index])) { - index += 1; - } - } - if (pattern[index] !== '}') - return 0; - return index - startIndex + 1; + } + if (pattern[index] !== '}') return 0; + return index - startIndex + 1; } + +function isAllowedSafeRegexCharacter(ch) { + if (!ch) return false; + const code = ch.charCodeAt(0); + const isLowerAlpha = code >= 97 && code <= 122; + const isUpperAlpha = code >= 65 && code <= 90; + const isDigit = code >= 48 && code <= 57; + if (isLowerAlpha || isUpperAlpha || isDigit) return true; + return ' .^$|()[]{}+*?\\:_/-'.includes(ch); +} + +function hasUnsafeRegexBackreference(body) { + for (let index = 0; index < body.length; index += 1) { + if (body[index] !== '\\') continue; + const prev = index > 0 ? body[index - 1] : ''; + const next = body[index + 1]; + if (prev !== '\\' && isDigitCharacter(next) && next !== '0') { + return true; + } + } + return false; +} + function isSafeRegexPatternBody(body) { - if (!body || body.length > 256) - return false; - if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) - return false; - if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(? 256) return false; + for (const ch of body) { + if (!isAllowedSafeRegexCharacter(ch)) return false; + } + if ( + body.includes('(?=') + || body.includes('(?!') + || body.includes('(?<=') + || body.includes('(? 0) { + groupStack[groupStack.length - 1].hasAlternation = true; + } + continue; + } + if (ch === ')') { + const group = groupStack.pop(); + if (!group) return false; + const quantifierLength = readRegexQuantifierLength(body, index + 1); + if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) { return false; + } + const parent = groupStack[groupStack.length - 1]; + if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) { + parent.hasInnerQuantifier = true; + } + continue; } - const groupStack = []; - let escaped = false; - let inCharClass = false; - for (let index = 0; index < body.length; index += 1) { - const ch = body[index]; - if (escaped) { - escaped = false; - continue; - } - if (ch === '\\') { - escaped = true; - continue; - } - if (inCharClass) { - if (ch === ']') - inCharClass = false; - continue; - } - if (ch === '[') { - inCharClass = true; - continue; - } - if (ch === '(') { - if (body[index + 1] === '?') { - if (body[index + 2] !== ':') - return false; - groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); - index += 2; - continue; - } - groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); - continue; - } - if (ch === '|') { - if (groupStack.length > 0) { - groupStack[groupStack.length - 1].hasAlternation = true; - } - continue; - } - if (ch === ')') { - const group = groupStack.pop(); - if (!group) - return false; - const quantifierLength = readRegexQuantifierLength(body, index + 1); - if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) { - return false; - } - const parent = groupStack[groupStack.length - 1]; - if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) { - parent.hasInnerQuantifier = true; - } - continue; - } - const quantifierLength = readRegexQuantifierLength(body, index); - if (quantifierLength > 0) { - if (groupStack.length > 0) { - groupStack[groupStack.length - 1].hasInnerQuantifier = true; - } - index += quantifierLength - 1; - } + const quantifierLength = readRegexQuantifierLength(body, index); + if (quantifierLength > 0) { + if (groupStack.length > 0) { + groupStack[groupStack.length - 1].hasInnerQuantifier = true; + } + index += quantifierLength - 1; } - return !escaped && !inCharClass && groupStack.length === 0; + } + return !escaped && !inCharClass && groupStack.length === 0; } -function globToRegexSource(glob) { - let source = ''; - for (let i = 0; i < glob.length; i += 1) { - const ch = glob[i]; - if (ch === '*') { - source += '.*'; - continue; - } - if (ch === '?') { - source += '.'; - continue; - } - if (ch === '[') { - const closeIndex = glob.indexOf(']', i + 1); - if (closeIndex > i + 1) { - source += glob.slice(i, closeIndex + 1); - i = closeIndex; - continue; - } - source += '\\['; - continue; - } - source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&'); + +function matchesGlobPattern(model, pattern) { + let modelIndex = 0; + let patternIndex = 0; + let starIndex = -1; + let matchIndex = 0; + + while (modelIndex < model.length) { + const patternChar = pattern[patternIndex]; + const modelChar = model[modelIndex]; + if (patternChar === '*' ) { + starIndex = patternIndex; + matchIndex = modelIndex; + patternIndex += 1; + continue; } - return source; + if (patternChar === '?' || patternChar === modelChar) { + patternIndex += 1; + modelIndex += 1; + continue; + } + if (starIndex === -1) { + return false; + } + patternIndex = starIndex + 1; + matchIndex += 1; + modelIndex = matchIndex; + } + + while (pattern[patternIndex] === '*') { + patternIndex += 1; + } + + return patternIndex === pattern.length; } -const compiledGlobCache = new Map(); + +function toArraySet(values) { + const unique = []; + for (const value of values) { + if (!unique.includes(value)) unique.push(value); + } + return unique; +} + +function parseCharClassChar(body, state) { + const ch = body[state.index]; + if (ch === '\\') { + state.index += 1; + const escaped = body[state.index]; + if (!escaped) throw new Error('invalid escape'); + state.index += 1; + if (escaped === 'd') return { kind: 'digit' }; + return { kind: 'char', value: escaped }; + } + if (!ch) throw new Error('invalid char class'); + state.index += 1; + return { kind: 'char', value: ch }; +} + +function parseCharClass(body, state) { + state.index += 1; + let negated = false; + if (body[state.index] === '^') { + negated = true; + state.index += 1; + } + + const entries = []; + while (state.index < body.length && body[state.index] !== ']') { + const start = parseCharClassChar(body, state); + if (body[state.index] === '-' && body[state.index + 1] && body[state.index + 1] !== ']') { + state.index += 1; + const end = parseCharClassChar(body, state); + entries.push({ kind: 'range', start, end }); + continue; + } + entries.push(start); + } + + if (body[state.index] !== ']' || entries.length === 0) { + throw new Error('invalid character class'); + } + state.index += 1; + return createNode('charclass', { negated, entries }); +} + +function parseAtom(body, state) { + const ch = body[state.index]; + if (!ch) throw new Error('unexpected end'); + + if (ch === '(') { + state.index += 1; + const group = parseExpression(body, state, ')'); + if (body[state.index] !== ')') { + throw new Error('missing )'); + } + state.index += 1; + return group; + } + if (ch === '[') { + return parseCharClass(body, state); + } + if (ch === '.') { + state.index += 1; + return createNode('any'); + } + if (ch === '\\') { + state.index += 1; + const escaped = body[state.index]; + if (!escaped) throw new Error('invalid escape'); + state.index += 1; + if (escaped === 'd') { + return createNode('digit'); + } + return createNode('literal', { value: escaped }); + } + + state.index += 1; + return createNode('literal', { value: ch }); +} + +function parseQuantifier(body, state) { + const ch = body[state.index]; + if (ch === '*') { + state.index += 1; + return { min: 0, max: Infinity }; + } + if (ch === '+') { + state.index += 1; + return { min: 1, max: Infinity }; + } + if (ch === '?') { + state.index += 1; + return { min: 0, max: 1 }; + } + if (ch !== '{') return null; + + let index = state.index + 1; + let minText = ''; + while (index < body.length && isDigitCharacter(body[index])) { + minText += body[index]; + index += 1; + } + if (!minText) return null; + + let max = Number.parseInt(minText, 10); + if (body[index] === ',') { + index += 1; + let maxText = ''; + while (index < body.length && isDigitCharacter(body[index])) { + maxText += body[index]; + index += 1; + } + max = maxText ? Number.parseInt(maxText, 10) : Infinity; + } + if (body[index] !== '}') return null; + state.index = index + 1; + return { + min: Number.parseInt(minText, 10), + max, + }; +} + +function parseTerm(body, state) { + const atom = parseAtom(body, state); + const quantifier = parseQuantifier(body, state); + if (!quantifier) return atom; + return createNode('repeat', { + atom, + min: quantifier.min, + max: quantifier.max, + }); +} + +function parseSequence(body, state, stopChar) { + const terms = []; + while (state.index < body.length) { + const ch = body[state.index]; + if (ch === '|' || ch === stopChar) break; + terms.push(parseTerm(body, state)); + } + if (terms.length === 0) { + return createNode('empty'); + } + if (terms.length === 1) return terms[0]; + return createNode('sequence', { terms }); +} + +function parseExpression(body, state, stopChar = '') { + const branches = [parseSequence(body, state, stopChar)]; + while (state.index < body.length && body[state.index] === '|') { + state.index += 1; + branches.push(parseSequence(body, state, stopChar)); + } + if (branches.length === 1) return branches[0]; + return createNode('alternation', { branches }); +} + +function charMatchesCharClassEntry(entry, ch) { + if (entry.kind === 'digit') return isDigitCharacter(ch); + if (entry.kind === 'char') return ch === entry.value; + if (entry.kind === 'range') { + if (entry.start.kind !== 'char' || entry.end.kind !== 'char') { + return false; + } + const code = ch.charCodeAt(0); + return code >= entry.start.value.charCodeAt(0) && code <= entry.end.value.charCodeAt(0); + } + return false; +} + +function matchNode(node, value, position, cache) { + const cacheKey = `${node.id}:${position}`; + const cached = cache.get(cacheKey); + if (cache.has(cacheKey)) return cached; + + let result = []; + switch (node.type) { + case 'empty': + result = [position]; + break; + case 'literal': + result = value.startsWith(node.value, position) ? [position + node.value.length] : []; + break; + case 'any': + result = position < value.length ? [position + 1] : []; + break; + case 'digit': + result = position < value.length && isDigitCharacter(value[position]) ? [position + 1] : []; + break; + case 'charclass': { + if (position < value.length) { + const matched = node.entries.some((entry) => charMatchesCharClassEntry(entry, value[position])); + if ((matched && !node.negated) || (!matched && node.negated)) { + result = [position + 1]; + } + } + break; + } + case 'sequence': { + let positions = [position]; + for (const term of node.terms) { + const nextPositions = []; + for (const current of positions) { + nextPositions.push(...matchNode(term, value, current, cache)); + } + positions = toArraySet(nextPositions); + if (positions.length === 0) break; + } + result = positions; + break; + } + case 'alternation': { + const positions = []; + for (const branch of node.branches) { + positions.push(...matchNode(branch, value, position, cache)); + } + result = toArraySet(positions); + break; + } + case 'repeat': { + let results = node.min === 0 ? [position] : []; + let frontier = [position]; + const maxRepeat = Number.isFinite(node.max) ? node.max : (value.length - position + 1); + for (let count = 1; count <= maxRepeat; count += 1) { + const nextPositions = []; + for (const current of frontier) { + const ends = matchNode(node.atom, value, current, cache); + for (const end of ends) { + if (end !== current) nextPositions.push(end); + } + } + frontier = toArraySet(nextPositions); + if (frontier.length === 0) break; + if (count >= node.min) { + results = toArraySet([...results, ...frontier]); + } + } + result = results; + break; + } + default: + result = []; + break; + } + + cache.set(cacheKey, result); + return result; +} + +function compileSafeRegexPattern(body) { + nextNodeId = 1; + const anchoredStart = body.startsWith('^'); + const anchoredEnd = body.endsWith('$') && body[body.length - 2] !== '\\'; + const normalizedBody = body + .slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : body.length) + .trim(); + const state = { index: 0 }; + const root = parseExpression(normalizedBody, state); + if (state.index !== normalizedBody.length) { + throw new Error('unsupported regex syntax'); + } + + return { + test(value) { + const starts = anchoredStart + ? [0] + : Array.from({ length: value.length + 1 }, (_, index) => index); + for (const start of starts) { + const ends = matchNode(root, value, start, new Map()); + if (!anchoredEnd && ends.length > 0) return true; + if (anchoredEnd && ends.includes(value.length)) return true; + } + return false; + }, + }; +} + const matchCache = new Map(); const MATCH_CACHE_LIMIT = 4000; -function matchesGlobPattern(model, pattern) { - let re = compiledGlobCache.get(pattern); - if (re === undefined) { - try { - re = new RegExp(`^${globToRegexSource(pattern)}$`); - } - catch { - re = null; - } - compiledGlobCache.set(pattern, re); - } - return re ? re.test(model) : false; -} + export function isTokenRouteRegexPattern(pattern) { - return pattern.trim().toLowerCase().startsWith('re:'); + return pattern.trim().toLowerCase().startsWith('re:'); } + export function isExactTokenRouteModelPattern(pattern) { - const normalized = pattern.trim(); - if (!normalized) - return false; - if (isTokenRouteRegexPattern(normalized)) - return false; - return !/[\*\?]/.test(normalized); + const normalized = pattern.trim(); + if (!normalized) return false; + if (isTokenRouteRegexPattern(normalized)) return false; + return !/[\*\?]/.test(normalized); } + export function parseTokenRouteRegexPattern(pattern) { - if (!isTokenRouteRegexPattern(pattern)) - return { regex: null, error: null }; - const body = pattern.trim().slice(3).trim(); - if (!body) - return { regex: null, error: 're: 后缺少正则表达式' }; - if (!isSafeRegexPatternBody(body)) { - return { regex: null, error: '出于安全原因不支持该正则表达式' }; - } - try { - return { regex: new RegExp(body), error: null }; - } - catch (error) { - return { regex: null, error: error?.message || '无效正则' }; - } + if (!isTokenRouteRegexPattern(pattern)) { + return { regex: null, error: null }; + } + const body = pattern.trim().slice(3).trim(); + if (!body) { + return { regex: null, error: 're: 后缺少正则表达式' }; + } + if (!isSafeRegexPatternBody(body)) { + return { regex: null, error: '出于安全原因不支持该正则表达式' }; + } + try { + return { + regex: compileSafeRegexPattern(body), + error: null, + }; + } catch (error) { + return { regex: null, error: error?.message || '无效正则' }; + } } + export function matchesTokenRouteModelPattern(model, pattern) { - const normalized = (pattern || '').trim(); - if (!normalized) - return false; - if (normalized === model) - return true; - const cacheKey = `${model}\0${normalized}`; - const cached = matchCache.get(cacheKey); - if (cached !== undefined) - return cached; - let result; - if (isTokenRouteRegexPattern(normalized)) { - const parsed = parseTokenRouteRegexPattern(normalized); - result = !!parsed.regex && parsed.regex.test(model); - } - else { - result = matchesGlobPattern(model, normalized); - } - if (matchCache.size >= MATCH_CACHE_LIMIT) - matchCache.clear(); - matchCache.set(cacheKey, result); - return result; + const normalized = (pattern || '').trim(); + if (!normalized) return false; + if (normalized === model) return true; + + const cacheKey = `${model}\0${normalized}`; + const cached = matchCache.get(cacheKey); + if (cached !== undefined) return cached; + + let result; + if (isTokenRouteRegexPattern(normalized)) { + const parsed = parseTokenRouteRegexPattern(normalized); + result = !!parsed.regex && parsed.regex.test(model); + } else { + result = matchesGlobPattern(model, normalized); + } + + if (matchCache.size >= MATCH_CACHE_LIMIT) { + matchCache.clear(); + } + matchCache.set(cacheKey, result); + return result; } diff --git a/src/shared/tokenRoutePatterns.test.ts b/src/shared/tokenRoutePatterns.test.ts index 72cd4a5..f5da960 100644 --- a/src/shared/tokenRoutePatterns.test.ts +++ b/src/shared/tokenRoutePatterns.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest'; describe('token route pattern helpers', () => { it('treats bracket-prefixed literal model names as exact patterns', async () => { - const { isExactTokenRouteModelPattern } = await import('./tokenRoutePatterns.js'); + const { + isExactTokenRouteModelPattern, + matchesTokenRouteModelPattern, + } = await import('./tokenRoutePatterns.js'); expect(isExactTokenRouteModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true); + expect(matchesTokenRouteModelPattern('[NV]deepseek-v3.1-terminus', '[NV]deepseek-v3.1-terminus')).toBe(true); + expect(matchesTokenRouteModelPattern('Ndeepseek-v3.1-terminus', '[NV]deepseek-v3.1-terminus')).toBe(false); }); it('rejects unsafe nested-quantifier regex patterns', async () => { @@ -12,8 +17,8 @@ describe('token route pattern helpers', () => { parseTokenRouteRegexPattern, } = await import('./tokenRoutePatterns.js'); - expect(parseTokenRouteRegexPattern('re:^(a+)+$').regex).toBeNull(); - expect(matchesTokenRouteModelPattern('aaaa', 're:^(a+)+$')).toBe(false); + expect(parseTokenRouteRegexPattern('re:(?=claude)').regex).toBeNull(); + expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false); }); it('supports exact, glob, and safe regex route matches', async () => { @@ -22,5 +27,6 @@ describe('token route pattern helpers', () => { expect(matchesTokenRouteModelPattern('gpt-4o-mini', 'gpt-4o-mini')).toBe(true); expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 'claude-*')).toBe(true); expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:^claude-(opus|sonnet)-4-6$')).toBe(true); + expect(matchesTokenRouteModelPattern('gpt-4o-mini-2025', 're:^gpt-4o-mini-\\d+$')).toBe(true); }); }); diff --git a/src/web/api.ts b/src/web/api.ts index 76e83f1..65c9e17 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -208,7 +208,7 @@ async function proxyTestStreamRequest(data: ProxyTestRequestEnvelope, signal?: A clearAuthSession(localStorage); throw new Error('Session expired'); } - return fetch('/api/test/proxy/stream', { + const response = await fetch('/api/test/proxy/stream', { method: 'POST', signal, headers: { @@ -217,6 +217,11 @@ async function proxyTestStreamRequest(data: ProxyTestRequestEnvelope, signal?: A }, body: JSON.stringify(data), }); + if (response.status === 401 || response.status === 403) { + clearAuthSession(localStorage); + throw new Error('Session expired'); + } + return response; } export type ProxyTestJobResponse = { diff --git a/src/web/pages/helpers/accountConnection.test.ts b/src/web/pages/helpers/accountConnection.test.ts index b44a415..dd9d231 100644 --- a/src/web/pages/helpers/accountConnection.test.ts +++ b/src/web/pages/helpers/accountConnection.test.ts @@ -10,6 +10,11 @@ describe('accountConnection helpers', () => { expect(resolveAccountCredentialMode({ credentialMode: 'apikey' })).toBe('apikey'); expect(resolveAccountCredentialMode({ capabilities: { proxyOnly: true } })).toBe('apikey'); expect(resolveAccountCredentialMode({ accessToken: ' session-token ' })).toBe('session'); + expect(resolveAccountCredentialMode({ + credentialMode: 'session', + capabilities: { proxyOnly: true }, + accessToken: 'api-token-ignored-by-explicit-mode', + })).toBe('session'); expect(resolveAccountCredentialMode({})).toBe('apikey'); }); @@ -17,6 +22,9 @@ describe('accountConnection helpers', () => { expect(parsePositiveInt('42')).toBe(42); expect(parsePositiveInt(' 0 ')).toBe(0); expect(parsePositiveInt('abc')).toBe(0); + expect(parsePositiveInt('42abc')).toBe(0); + expect(parsePositiveInt('1e3')).toBe(0); + expect(parsePositiveInt('1.5')).toBe(0); expect(parsePositiveInt(null)).toBe(0); }); diff --git a/src/web/pages/helpers/accountConnection.ts b/src/web/pages/helpers/accountConnection.ts index bc55524..66db7ed 100644 --- a/src/web/pages/helpers/accountConnection.ts +++ b/src/web/pages/helpers/accountConnection.ts @@ -11,7 +11,9 @@ export function resolveAccountCredentialMode(account: any): 'session' | 'apikey' } export function parsePositiveInt(input: string | null): number { - const value = Number.parseInt(String(input || '').trim(), 10); + const normalized = String(input || '').trim(); + if (!/^\d+$/.test(normalized)) return 0; + const value = Number.parseInt(normalized, 10); if (!Number.isFinite(value) || value <= 0) return 0; return value; } diff --git a/src/web/pages/helpers/conversationFileCapabilities.test.ts b/src/web/pages/helpers/conversationFileCapabilities.test.ts index 3f6d460..232f4d1 100644 --- a/src/web/pages/helpers/conversationFileCapabilities.test.ts +++ b/src/web/pages/helpers/conversationFileCapabilities.test.ts @@ -75,5 +75,9 @@ describe('resolveConversationFileCapability', () => { filename: 'voice.mp3', mimeType: 'audio/mpeg', })).toBe(false); + expect(isConversationUploadedFileSupported(claudeCapability, { + filename: 'paper.pdf', + mimeType: 'application/octet-stream', + })).toBe(true); }); }); diff --git a/src/web/pages/token-routes/utils.ts b/src/web/pages/token-routes/utils.ts index 853db4d..0844b64 100644 --- a/src/web/pages/token-routes/utils.ts +++ b/src/web/pages/token-routes/utils.ts @@ -65,7 +65,7 @@ export function isRouteExactModel(route: Pick Date: Sun, 22 Mar 2026 23:53:41 +0800 Subject: [PATCH 05/24] fix: resolve remaining review follow-ups --- src/server/routes/proxy/inputFiles.test.ts | 10 ++++-- src/server/routes/proxy/logPathMeta.ts | 10 ++---- .../services/proxyInputFileResolver.test.ts | 11 +++--- src/shared/proxyLogMeta.test.ts | 34 +++++++++++++++++++ src/web/pages/Settings.tsx | 4 ++- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/src/server/routes/proxy/inputFiles.test.ts b/src/server/routes/proxy/inputFiles.test.ts index 843a420..7b2a604 100644 --- a/src/server/routes/proxy/inputFiles.test.ts +++ b/src/server/routes/proxy/inputFiles.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; const getProxyFileByPublicIdForOwnerMock = vi.fn(); -vi.mock('../../services/proxyFileStore.js', () => ({ - getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args), -})); +vi.mock('../../services/proxyFileStore.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args), + }; +}); describe('inlineLocalInputFileReferences', () => { beforeEach(() => { diff --git a/src/server/routes/proxy/logPathMeta.ts b/src/server/routes/proxy/logPathMeta.ts index 35a3175..eb4c3cb 100644 --- a/src/server/routes/proxy/logPathMeta.ts +++ b/src/server/routes/proxy/logPathMeta.ts @@ -1,4 +1,4 @@ -import { parseProxyLogMetadata } from '../../../shared/proxyLogMeta.js'; +import { parseProxyLogMetadata, type ParsedProxyLogMetadata } from '../../../shared/proxyLogMeta.js'; type ComposeProxyLogMessageArgs = { clientKind?: string | null; @@ -9,13 +9,7 @@ type ComposeProxyLogMessageArgs = { errorMessage?: string | null; }; -export type ParsedProxyLogMessageMeta = { - clientKind: string | null; - sessionId: string | null; - downstreamPath: string | null; - upstreamPath: string | null; - messageText: string; -}; +export type ParsedProxyLogMessageMeta = ParsedProxyLogMetadata; export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta { return parseProxyLogMetadata(rawMessage); diff --git a/src/server/services/proxyInputFileResolver.test.ts b/src/server/services/proxyInputFileResolver.test.ts index 10b4c3b..9b68bc9 100644 --- a/src/server/services/proxyInputFileResolver.test.ts +++ b/src/server/services/proxyInputFileResolver.test.ts @@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const getProxyFileByPublicIdForOwnerMock = vi.fn(); -vi.mock('./proxyFileStore.js', () => ({ - getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args), - LOCAL_PROXY_FILE_ID_PREFIX: 'file-metapi-', -})); +vi.mock('./proxyFileStore.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args), + }; +}); describe('proxyInputFileResolver', () => { beforeEach(() => { diff --git a/src/shared/proxyLogMeta.test.ts b/src/shared/proxyLogMeta.test.ts index 1e77042..4bec696 100644 --- a/src/shared/proxyLogMeta.test.ts +++ b/src/shared/proxyLogMeta.test.ts @@ -24,4 +24,38 @@ describe('proxyLogMeta', () => { messageText: 'network timeout', }); }); + + it('handles mixed-case, partial, reordered, and empty metadata safely', () => { + expect(parseProxyLogMetadata('[CLIENT:codex] [SESSION:turn-1] boom')).toEqual({ + clientKind: 'codex', + sessionId: 'turn-1', + downstreamPath: null, + upstreamPath: null, + messageText: 'boom', + }); + + expect(parseProxyLogMetadata('[client:codex] boom')).toEqual({ + clientKind: 'codex', + sessionId: null, + downstreamPath: null, + upstreamPath: null, + messageText: 'boom', + }); + + expect(parseProxyLogMetadata('[upstream:/x] [client:codex] [session:1] msg')).toEqual({ + clientKind: 'codex', + sessionId: '1', + downstreamPath: null, + upstreamPath: '/x', + messageText: 'msg', + }); + + expect(parseProxyLogMetadata('')).toEqual({ + clientKind: null, + sessionId: null, + downstreamPath: null, + upstreamPath: null, + messageText: '', + }); + }); }); diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 09fcbb2..e110590 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -290,7 +290,7 @@ export default function Settings() { const query = selectorGroupSearch.trim(); if (!query) return groupRouteOptions; return groupRouteOptions.filter((route) => { - const matchText = `${resolveRouteTitle(route)} ${route.modelPattern} ${route.displayName || ''}`; + const matchText = `${resolveRouteTitle(route)} ${route.modelPattern}`; return fuzzyMatch(matchText, query); }); }, [groupRouteOptions, selectorGroupSearch]); @@ -1970,6 +1970,8 @@ export default function Settings() { ) : routeIcon.kind === 'auto' ? ( + ) : routeIcon.kind === 'none' ? ( + null ) : null} From 328b492ab3eb4e22a21bf87a5e1cfa5d7589b556 Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 11:26:01 +0800 Subject: [PATCH 06/24] fix: finish review-driven single-source cleanup --- .../proxy-core/providers/headerUtils.test.ts | 11 +- .../proxy-core/providers/headerUtils.ts | 48 ++- src/server/proxy-core/surfaces/chatSurface.ts | 152 +++----- .../surfaces/openAiResponsesSurface.ts | 157 ++------ .../proxy-core/surfaces/sharedSurface.test.ts | 358 +++++++++++++++++- .../proxy-core/surfaces/sharedSurface.ts | 209 +++++++++- .../api/accountTokens.architecture.test.ts | 13 + src/server/routes/api/accountTokens.ts | 1 - src/server/routes/api/accounts.ts | 19 +- .../proxy/architecture-boundaries.test.ts | 23 ++ src/server/routes/proxy/upstreamEndpoint.ts | 43 +-- .../services/accountMutationWorkflow.test.ts | 35 ++ .../services/accountMutationWorkflow.ts | 20 +- .../platforms/standardApiProvider.test.ts | 38 ++ .../services/platforms/standardApiProvider.ts | 23 +- .../openai/responses/conversion.test.ts | 41 ++ .../openai/responses/normalization.ts | 9 +- src/shared/conversationFileTypes.js | 14 +- src/shared/conversationFileTypes.test.ts | 10 + src/shared/tokenRoutePatterns.js | 10 +- src/shared/tokenRoutePatterns.test.ts | 12 + 21 files changed, 926 insertions(+), 320 deletions(-) create mode 100644 src/server/routes/api/accountTokens.architecture.test.ts diff --git a/src/server/proxy-core/providers/headerUtils.test.ts b/src/server/proxy-core/providers/headerUtils.test.ts index a762d3a..b1c4398 100644 --- a/src/server/proxy-core/providers/headerUtils.test.ts +++ b/src/server/proxy-core/providers/headerUtils.test.ts @@ -25,7 +25,7 @@ describe('provider header utils', () => { ).toBe('beta-a,beta-b,beta-c'); expect( mergeClaudeBetaHeader('custom-a,custom-b', 'beta-a,beta-b', ['beta-c']), - ).toBe('custom-a,custom-b'); + ).toBe('beta-a,beta-b,custom-a,custom-b,beta-c'); }); it('preserves gemini cli runtime metadata when rebuilding user agents', async () => { @@ -79,9 +79,12 @@ describe('provider header utils', () => { const headers = buildClaudeRuntimeHeaders({ baseHeaders: { 'Content-Type': 'application/json', + authorization: 'Bearer stale-token', }, claudeHeaders: { 'anthropic-beta': 'custom-beta', + 'x-api-key': 'stale-api-key', + 'user-agent': 'custom-agent', }, anthropicVersion: '2023-06-01', stream: true, @@ -90,9 +93,13 @@ describe('provider header utils', () => { }); expect(headers['anthropic-version']).toBe('2023-06-01'); - expect(headers['anthropic-beta']).toBe('custom-beta'); + expect(headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(headers['anthropic-beta']).toContain('custom-beta'); expect(headers.Authorization).toBe('Bearer oauth-token'); + expect(headers.authorization).toBeUndefined(); expect(headers['x-api-key']).toBeUndefined(); + expect(headers['user-agent']).toBeUndefined(); + expect(headers['User-Agent']).toBe('custom-agent'); expect(headers.Accept).toBe('text/event-stream'); }); diff --git a/src/server/proxy-core/providers/headerUtils.ts b/src/server/proxy-core/providers/headerUtils.ts index f31600e..5846694 100644 --- a/src/server/proxy-core/providers/headerUtils.ts +++ b/src/server/proxy-core/providers/headerUtils.ts @@ -32,6 +32,24 @@ export function getInputHeader( return null; } +function normalizeLowerCaseHeaderMap( + sources: Array | Record | undefined>, + shouldSkip: (key: string) => boolean, +): Record { + const normalized: Record = {}; + for (const source of sources) { + if (!source) continue; + for (const [key, value] of Object.entries(source)) { + const normalizedValue = headerValueToString(value); + if (!normalizedValue) continue; + const normalizedKey = key.toLowerCase(); + if (shouldSkip(normalizedKey)) continue; + normalized[normalizedKey] = normalizedValue; + } + } + return normalized; +} + export function uuidFromSeed(seed: string): string { const derived = pbkdf2Sync(seed, 'metapi-runtime-header-seed', 10_000, 16, 'sha256'); const bytes = new Uint8Array(derived); @@ -52,17 +70,10 @@ export function mergeClaudeBetaHeader( defaultValue: string, extraBetas: string[] = [], ): string { - const source = explicitValue || defaultValue; const seen = new Set(); const merged: string[] = []; - for (const entry of source.split(',')) { - const normalized = entry.trim(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - merged.push(normalized); - } - if (!explicitValue) { - for (const entry of extraBetas) { + for (const source of [defaultValue, explicitValue ?? '', ...extraBetas]) { + for (const entry of source.split(',')) { const normalized = entry.trim(); if (!normalized || seen.has(normalized)) continue; seen.add(normalized); @@ -187,9 +198,24 @@ export function buildClaudeRuntimeHeaders(input: { input.defaultBetaHeader || CLAUDE_DEFAULT_BETA_HEADER, input.extraBetas, ); + const passthroughHeaders = normalizeLowerCaseHeaderMap( + [input.baseHeaders, input.claudeHeaders], + (key) => ( + key === 'accept' + || key === 'accept-encoding' + || key === 'anthropic-beta' + || key === 'anthropic-dangerous-direct-browser-access' + || key === 'anthropic-version' + || key === 'authorization' + || key === 'connection' + || key === 'user-agent' + || key === 'x-api-key' + || key === 'x-app' + || key.startsWith('x-stainless-') + ), + ); const headers: Record = { - ...input.baseHeaders, - ...input.claudeHeaders, + ...passthroughHeaders, 'anthropic-version': input.anthropicVersion, ...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}), 'Anthropic-Dangerous-Direct-Browser-Access': 'true', diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 2e0d0e3..37e93c5 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -2,7 +2,6 @@ import { TextDecoder } from 'node:util'; import type { FastifyRequest, FastifyReply } from 'fastify'; import { tokenRouter } from '../../services/tokenRouter.js'; import { reportProxyAllFailed } from '../../services/alertService.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { @@ -19,8 +18,6 @@ import { } from '../../routes/proxy/downstreamPolicy.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; -import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; -import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; @@ -32,7 +29,6 @@ import { buildOauthProviderHeaders, } from '../../services/oauth/service.js'; import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; -import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; import { collectResponsesFinalPayloadFromSse, collectResponsesFinalPayloadFromSseText, @@ -48,7 +44,9 @@ import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClie import { createSurfaceFailureToolkit, createSurfaceDispatchRequest, + recordSurfaceSuccess, selectSurfaceChannelForAttempt, + trySurfaceOauthRefreshRecovery, } from './sharedSurface.js'; const MAX_RETRIES = 2; @@ -224,28 +222,15 @@ export async function handleChatSurfaceRequest( }); const tryRecover = async (ctx: Parameters>[0]) => { if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) { - try { - const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); - selected.tokenValue = refreshed.accessToken; - selected.account = { - ...selected.account, - accessToken: refreshed.accessToken, - extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, - }; - const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); - const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path); - const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); - if (refreshedResponse.ok) { - return { - upstream: refreshedResponse, - upstreamPath: refreshedRequest.path, - }; - } - ctx.request = refreshedRequest; - ctx.response = refreshedResponse; - ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); - } catch { - return endpointStrategy.tryRecover(ctx); + const recovered = await trySurfaceOauthRefreshRecovery({ + ctx, + selected, + siteUrl: selected.site.url, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + }); + if (recovered?.upstream?.ok) { + return recovered; } } return endpointStrategy.tryRecover(ctx); @@ -477,46 +462,19 @@ export async function handleChatSurfaceRequest( } const latency = Date.now() - startTime; - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, + await recordSurfaceSuccess({ + selected, + requestedModel, modelName, parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); - recordDownstreamCostUsage(request, estimatedCost); - await failureToolkit.log({ - selected, - modelRequested: requestedModel, - status: 'success', - httpStatus: 200, + requestStartedAtMs: startTime, latencyMs: latency, - errorMessage: null, retryCount, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - estimatedCost, - billingDetails, upstreamPath: successfulUpstreamPath, + logSuccess: failureToolkit.log, + recordDownstreamCost: (estimatedCost) => { + recordDownstreamCostUsage(request, estimatedCost); + }, }); return; } @@ -570,46 +528,19 @@ export async function handleChatSurfaceRequest( const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText); const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage); - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, + await recordSurfaceSuccess({ + selected, + requestedModel, modelName, parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); - recordDownstreamCostUsage(request, estimatedCost); - await failureToolkit.log({ - selected, - modelRequested: requestedModel, - status: 'success', - httpStatus: 200, + requestStartedAtMs: startTime, latencyMs: latency, - errorMessage: null, retryCount, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - estimatedCost, - billingDetails, upstreamPath: successfulUpstreamPath, + logSuccess: failureToolkit.log, + recordDownstreamCost: (estimatedCost) => { + recordDownstreamCostUsage(request, estimatedCost); + }, }); return reply.send(downstreamResponse); @@ -768,18 +699,25 @@ export async function handleClaudeCountTokensSurfaceRequest( let upstream = await dispatchRequest(upstreamRequest); if ((upstream.status === 401 || upstream.status === 403) && oauth) { - try { - const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); - selected.tokenValue = refreshed.accessToken; - selected.account = { - ...selected.account, - accessToken: refreshed.accessToken, - extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, - }; + const recoverContext = { + request: upstreamRequest, + response: upstream, + rawErrText: '', + }; + const recovered = await trySurfaceOauthRefreshRecovery({ + ctx: recoverContext, + selected, + siteUrl: selected.site.url, + buildRequest: () => buildRequest(), + dispatchRequest, + captureFailureBody: false, + }); + if (recovered?.upstream?.ok) { upstreamRequest = buildRequest(); - upstream = await dispatchRequest(upstreamRequest); - } catch { - // Fall through to the regular upstream error handling below. + upstream = recovered.upstream; + } else { + upstreamRequest = recoverContext.request; + upstream = recoverContext.response; } } diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 49e7eb2..eff8055 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -1,8 +1,6 @@ import { TextDecoder } from 'node:util'; import type { FastifyReply, FastifyRequest } from 'fastify'; -import { tokenRouter } from '../../services/tokenRouter.js'; import { reportProxyAllFailed } from '../../services/alertService.js'; -import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { @@ -14,8 +12,6 @@ import { import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; -import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; -import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; import { @@ -26,7 +22,6 @@ import { buildOauthProviderHeaders, } from '../../services/oauth/service.js'; import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; -import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; import { collectResponsesFinalPayloadFromSse, collectResponsesFinalPayloadFromSseText, @@ -46,7 +41,9 @@ import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClie import { createSurfaceFailureToolkit, createSurfaceDispatchRequest, + recordSurfaceSuccess, selectSurfaceChannelForAttempt, + trySurfaceOauthRefreshRecovery, } from './sharedSurface.js'; const MAX_RETRIES = 2; @@ -325,28 +322,15 @@ export async function handleOpenAiResponsesSurfaceRequest( response: ctx.response, rawErrText: ctx.rawErrText || '', })) { - try { - const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); - selected.tokenValue = refreshed.accessToken; - selected.account = { - ...selected.account, - accessToken: refreshed.accessToken, - extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, - }; - const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); - const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path); - const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); - if (refreshedResponse.ok) { - return { - upstream: refreshedResponse, - upstreamPath: refreshedRequest.path, - }; - } - ctx.request = refreshedRequest; - ctx.response = refreshedResponse; - ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); - } catch { - return endpointStrategy.tryRecover(ctx); + const recovered = await trySurfaceOauthRefreshRecovery({ + ctx, + selected, + siteUrl: selected.site.url, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + }); + if (recovered?.upstream?.ok) { + return recovered; } } return endpointStrategy.tryRecover(ctx); @@ -410,65 +394,23 @@ export async function handleOpenAiResponsesSurfaceRequest( const upstream = endpointResult.upstream; const successfulUpstreamPath = endpointResult.upstreamPath; const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number) => { - let usageForLog = { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }; - let estimatedCost = 0; - let billingDetails: unknown = null; - try { - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - usageForLog = { - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - }; - const billing = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - estimatedCost = billing.estimatedCost; - billingDetails = billing.billingDetails; - } catch (error) { - console.error('[responses] post-stream bookkeeping failed:', error); - } - - try { - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); - recordDownstreamCostUsage(request, estimatedCost); - await failureToolkit.log({ + await recordSurfaceSuccess({ selected, - modelRequested: requestedModel, - status: 'success', - httpStatus: 200, + requestedModel, + modelName, + parsedUsage, + requestStartedAtMs: startTime, latencyMs: latency, - errorMessage: null, retryCount, - promptTokens: usageForLog.promptTokens, - completionTokens: usageForLog.completionTokens, - totalTokens: usageForLog.totalTokens, - estimatedCost, - billingDetails, upstreamPath: successfulUpstreamPath, + logSuccess: failureToolkit.log, + recordDownstreamCost: (estimatedCost) => { + recordDownstreamCostUsage(request, estimatedCost); + }, + bestEffortMetrics: { + errorLabel: '[responses] post-stream bookkeeping failed:', + }, }); } catch (error) { console.error('[responses] post-stream success logging failed:', error); @@ -716,48 +658,25 @@ export async function handleOpenAiResponsesSurfaceRequest( serializationMode: isCompactRequest ? 'compact' : 'response', }); try { - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, + await recordSurfaceSuccess({ + selected, + requestedModel, + modelName, + parsedUsage, requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, + latencyMs: latency, + retryCount, + upstreamPath: successfulUpstreamPath, + logSuccess: failureToolkit.log, + recordDownstreamCost: (estimatedCost) => { + recordDownstreamCostUsage(request, estimatedCost); + }, + bestEffortMetrics: { + errorLabel: '[responses] post-response bookkeeping failed:', }, }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); - recordDownstreamCostUsage(request, estimatedCost); - await failureToolkit.log({ - selected, - modelRequested: requestedModel, - status: 'success', - httpStatus: 200, - latencyMs: latency, - errorMessage: null, - retryCount, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - estimatedCost, - billingDetails, - upstreamPath: successfulUpstreamPath, - }); } catch (error) { - console.error('[responses] post-response bookkeeping failed:', error); + console.error('[responses] post-response success logging failed:', error); } return reply.send(downstreamData); } catch (err: any) { diff --git a/src/server/proxy-core/surfaces/sharedSurface.test.ts b/src/server/proxy-core/surfaces/sharedSurface.test.ts index 8a0ce8c..78a120c 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.test.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { EMPTY_DOWNSTREAM_ROUTING_POLICY } from '../../services/downstreamPolicyTypes.js'; const selectChannelMock = vi.fn(); @@ -16,12 +16,18 @@ const reportTokenExpiredMock = vi.fn(); const isTokenExpiredErrorMock = vi.fn(); const shouldRetryProxyRequestMock = vi.fn(); const recordOauthQuotaResetHintMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(); +const resolveProxyLogBillingMock = vi.fn(); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); +const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.mock('../../services/tokenRouter.js', () => ({ tokenRouter: { selectChannel: (...args: unknown[]) => selectChannelMock(...args), selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), recordFailure: (...args: unknown[]) => recordFailureMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), }, })); @@ -67,7 +73,23 @@ vi.mock('../../services/oauth/quota.js', () => ({ recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args), })); +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (...args: unknown[]) => resolveProxyUsageWithSelfLogFallbackMock(...args), +})); + +vi.mock('../../routes/proxy/proxyBilling.js', () => ({ + resolveProxyLogBilling: (...args: unknown[]) => resolveProxyLogBillingMock(...args), +})); + +vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), +})); + describe('selectSurfaceChannelForAttempt', () => { + afterAll(() => { + consoleWarnMock.mockRestore(); + }); + beforeEach(() => { selectChannelMock.mockReset(); selectNextChannelMock.mockReset(); @@ -84,6 +106,11 @@ describe('selectSurfaceChannelForAttempt', () => { isTokenExpiredErrorMock.mockReset(); shouldRetryProxyRequestMock.mockReset(); recordOauthQuotaResetHintMock.mockReset(); + recordSuccessMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockReset(); + resolveProxyLogBillingMock.mockReset(); + refreshOauthAccessTokenSingleflightMock.mockReset(); + consoleWarnMock.mockClear(); }); it('refreshes models and retries selectChannel on the first attempt when no channel is available', async () => { @@ -308,6 +335,40 @@ describe('selectSurfaceChannelForAttempt', () => { })); }); + it('keeps retryable failures on the retry path even when quota hint recording fails', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(true); + isTokenExpiredErrorMock.mockReturnValue(false); + recordOauthQuotaResetHintMock.mockRejectedValue(new Error('hint failed')); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'chat', + downstreamPath: '/v1/chat/completions', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + await expect(toolkit.handleUpstreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + status: 429, + errText: 'quota exceeded', + rawErrText: '{"error":"quota exceeded"}', + latencyMs: 1200, + retryCount: 0, + })).resolves.toEqual({ action: 'retry' }); + }); + it('returns a terminal upstream error response and reports token expiration when retries stop', async () => { composeProxyLogMessageMock.mockReturnValue('normalized error'); formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); @@ -363,6 +424,50 @@ describe('selectSurfaceChannelForAttempt', () => { }); }); + it('returns terminal failures even when final alerting throws', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + shouldRetryProxyRequestMock.mockReturnValue(false); + isTokenExpiredErrorMock.mockReturnValue(true); + recordOauthQuotaResetHintMock.mockResolvedValue(null); + reportTokenExpiredMock.mockRejectedValue(new Error('token alert failed')); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath: '/v1/responses', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + await expect(toolkit.handleUpstreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + status: 401, + errText: 'expired token', + rawErrText: 'expired token', + latencyMs: 900, + retryCount: 2, + })).resolves.toEqual({ + action: 'respond', + status: 401, + payload: { + error: { + message: 'expired token', + type: 'upstream_error', + }, + }, + }); + }); + it('handles detected proxy failures through the shared failure toolkit', async () => { composeProxyLogMessageMock.mockReturnValue('normalized error'); formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); @@ -468,4 +573,255 @@ describe('selectSurfaceChannelForAttempt', () => { reason: 'socket hang up', }); }); + + it('records stream failures with error text even without a runtime status code', async () => { + composeProxyLogMessageMock.mockReturnValue('normalized error'); + formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00'); + insertProxyLogMock.mockResolvedValue(undefined); + + const { createSurfaceFailureToolkit } = await import('./sharedSurface.js'); + const toolkit = createSurfaceFailureToolkit({ + warningScope: 'responses', + downstreamPath: '/v1/responses', + maxRetries: 2, + clientContext: null, + downstreamApiKeyId: null, + }); + + await toolkit.recordStreamFailure({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { name: 'Codex OAuth' }, + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + errorMessage: 'stream exploded', + latencyMs: 450, + retryCount: 1, + }); + + expect(recordFailureMock).toHaveBeenCalledWith(11, { + errorText: 'stream exploded', + modelName: 'upstream-model', + }); + }); + + it('refreshes oauth tokens through the shared recover helper and retries the rebuilt request', async () => { + const refreshedResponse = { + ok: true, + status: 200, + text: vi.fn(), + }; + const selected = { + account: { + id: 33, + accessToken: 'old-access-token', + extraConfig: '{"oauth":{"refreshToken":"refresh"}}', + }, + tokenValue: 'old-access-token', + }; + const ctx = { + request: { + endpoint: 'responses' as const, + path: '/v1/responses', + headers: { authorization: 'Bearer old-access-token' }, + body: { model: 'gpt-5.2' }, + }, + response: { + ok: false, + status: 401, + text: vi.fn(), + }, + rawErrText: 'expired token', + }; + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'new-access-token', + extraConfig: '{"oauth":{"refreshToken":"refresh-next"}}', + }); + const dispatchRequest = vi.fn().mockResolvedValue(refreshedResponse); + + const { trySurfaceOauthRefreshRecovery } = await import('./sharedSurface.js'); + const result = await trySurfaceOauthRefreshRecovery({ + ctx, + selected, + siteUrl: 'https://upstream.example.com', + buildRequest: () => ({ + endpoint: 'responses', + path: '/v1/responses', + headers: { authorization: `Bearer ${selected.tokenValue}` }, + body: { model: 'gpt-5.2' }, + }), + dispatchRequest, + }); + + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(selected.tokenValue).toBe('new-access-token'); + expect(selected.account.accessToken).toBe('new-access-token'); + expect(selected.account.extraConfig).toBe('{"oauth":{"refreshToken":"refresh-next"}}'); + expect(dispatchRequest).toHaveBeenCalledWith(expect.objectContaining({ + headers: { authorization: 'Bearer new-access-token' }, + }), 'https://upstream.example.com/v1/responses'); + expect(result).toEqual({ + upstream: refreshedResponse, + upstreamPath: '/v1/responses', + }); + }); + + it('updates the recover context with the refreshed failure response when oauth refresh retry still fails', async () => { + const refreshedResponse = { + ok: false, + status: 403, + text: vi.fn().mockResolvedValue('account mismatch'), + }; + const ctx = { + request: { + endpoint: 'responses' as const, + path: '/v1/responses', + headers: { authorization: 'Bearer old-access-token' }, + body: { model: 'gpt-5.2' }, + }, + response: { + ok: false, + status: 401, + text: vi.fn(), + }, + rawErrText: 'expired token', + }; + const selected = { + account: { + id: 33, + accessToken: 'old-access-token', + extraConfig: '{"oauth":{"refreshToken":"refresh"}}', + }, + tokenValue: 'old-access-token', + }; + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'new-access-token', + extraConfig: '{"oauth":{"refreshToken":"refresh-next"}}', + }); + + const { trySurfaceOauthRefreshRecovery } = await import('./sharedSurface.js'); + const result = await trySurfaceOauthRefreshRecovery({ + ctx, + selected, + siteUrl: 'https://upstream.example.com', + buildRequest: () => ({ + endpoint: 'responses', + path: '/v1/responses', + headers: { authorization: `Bearer ${selected.tokenValue}` }, + body: { model: 'gpt-5.2' }, + }), + dispatchRequest: vi.fn().mockResolvedValue(refreshedResponse), + }); + + expect(result).toBeNull(); + expect(ctx.request.headers).toEqual({ authorization: 'Bearer new-access-token' }); + expect(ctx.response).toBe(refreshedResponse); + expect(ctx.rawErrText).toBe('account mismatch'); + }); + + it('records shared success bookkeeping with usage fallback, billing, and success logging', async () => { + resolveProxyUsageWithSelfLogFallbackMock.mockResolvedValue({ + promptTokens: 20, + completionTokens: 8, + totalTokens: 28, + }); + resolveProxyLogBillingMock.mockResolvedValue({ + estimatedCost: 0.42, + billingDetails: { source: 'pricing-test' }, + }); + const logSuccess = vi.fn().mockResolvedValue(undefined); + const recordDownstreamCost = vi.fn(); + + const { recordSurfaceSuccess } = await import('./sharedSurface.js'); + const result = await recordSurfaceSuccess({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + tokenValue: 'live-token', + tokenName: 'default', + actualModel: 'upstream-model', + }, + requestedModel: 'gpt-5.2', + modelName: 'upstream-model', + parsedUsage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + requestStartedAtMs: 1000, + latencyMs: 250, + retryCount: 1, + upstreamPath: '/v1/responses', + logSuccess, + recordDownstreamCost, + }); + + expect(resolveProxyUsageWithSelfLogFallbackMock).toHaveBeenCalledWith({ + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + account: { id: 33, username: 'oauth-user' }, + tokenValue: 'live-token', + tokenName: 'default', + modelName: 'upstream-model', + requestStartedAtMs: 1000, + requestEndedAtMs: 1250, + localLatencyMs: 250, + usage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + }); + expect(resolveProxyLogBillingMock).toHaveBeenCalledWith({ + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + account: { id: 33, username: 'oauth-user' }, + modelName: 'upstream-model', + parsedUsage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + resolvedUsage: { + promptTokens: 20, + completionTokens: 8, + totalTokens: 28, + }, + }); + expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0.42, 'upstream-model'); + expect(recordDownstreamCost).toHaveBeenCalledWith(0.42); + expect(logSuccess).toHaveBeenCalledWith({ + selected: { + channel: { id: 11, routeId: 22 }, + account: { id: 33, username: 'oauth-user' }, + site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' }, + tokenValue: 'live-token', + tokenName: 'default', + actualModel: 'upstream-model', + }, + modelRequested: 'gpt-5.2', + status: 'success', + httpStatus: 200, + latencyMs: 250, + errorMessage: null, + retryCount: 1, + promptTokens: 20, + completionTokens: 8, + totalTokens: 28, + estimatedCost: 0.42, + billingDetails: { source: 'pricing-test' }, + upstreamPath: '/v1/responses', + }); + expect(result).toEqual({ + resolvedUsage: { + promptTokens: 20, + completionTokens: 8, + totalTokens: 28, + }, + estimatedCost: 0.42, + billingDetails: { source: 'pricing-test' }, + }); + }); }); diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index 8e4c559..e2a41f7 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -3,16 +3,20 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import type { SiteProxyConfigLike } from '../../services/siteProxy.js'; import { tokenRouter } from '../../services/tokenRouter.js'; +import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import type { DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; +import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; +import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; type SelectedChannel = Awaited>; type SurfaceWarningScope = 'chat' | 'responses'; @@ -39,6 +43,34 @@ type SurfaceFailureOutcome = | { action: 'retry' } | SurfaceFailureResponse; +type SurfaceOauthRefreshSelectedChannel = { + account: { + id: number; + accessToken?: string | null; + extraConfig?: string | null; + }; + tokenValue: string; +}; + +type SurfaceOauthRefreshContext = { + request: TRequest; + response: Awaited>; + rawErrText: string; +}; + +type SurfaceSuccessSelectedChannel = SurfaceSelectedChannel & { + account: Record & { id: number }; + site: Record; + tokenValue: string; + tokenName?: string | null; +}; + +type SurfaceUsageSummary = { + promptTokens: number; + completionTokens: number; + totalTokens: number; +}; + export async function selectSurfaceChannelForAttempt(input: { requestedModel: string; downstreamPolicy: DownstreamRoutingPolicy; @@ -146,6 +178,150 @@ export function createSurfaceDispatchRequest(input: { ); } +export async function trySurfaceOauthRefreshRecovery(input: { + ctx: SurfaceOauthRefreshContext; + selected: SurfaceOauthRefreshSelectedChannel; + siteUrl: string; + buildRequest: (endpoint: TRequest['endpoint']) => TRequest; + dispatchRequest: ( + request: TRequest, + targetUrl: string, + ) => Promise>>; + captureFailureBody?: boolean; +}): Promise<{ + upstream: Awaited>; + upstreamPath: string; +} | null> { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(input.selected.account.id); + input.selected.tokenValue = refreshed.accessToken; + input.selected.account = { + ...input.selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? input.selected.account.extraConfig, + }; + + const refreshedRequest = input.buildRequest(input.ctx.request.endpoint); + const refreshedTargetUrl = buildUpstreamUrl(input.siteUrl, refreshedRequest.path); + const refreshedResponse = await input.dispatchRequest(refreshedRequest, refreshedTargetUrl); + if (refreshedResponse.ok) { + return { + upstream: refreshedResponse, + upstreamPath: refreshedRequest.path, + }; + } + + input.ctx.request = refreshedRequest; + input.ctx.response = refreshedResponse; + if (input.captureFailureBody !== false) { + input.ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); + } + } catch { + return null; + } + + return null; +} + +export async function recordSurfaceSuccess(input: { + selected: SurfaceSuccessSelectedChannel; + requestedModel: string; + modelName: string; + parsedUsage: SurfaceUsageSummary; + requestStartedAtMs: number; + latencyMs: number; + retryCount: number; + upstreamPath?: string | null; + logSuccess: (args: { + selected: SurfaceSelectedChannel; + modelRequested: string; + status: string; + httpStatus: number; + latencyMs: number; + errorMessage: string | null; + retryCount: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + estimatedCost?: number; + billingDetails?: unknown; + upstreamPath?: string | null; + }) => Promise; + recordDownstreamCost?: (estimatedCost: number) => void; + bestEffortMetrics?: { + errorLabel: string; + }; +}) { + let resolvedUsage = { + promptTokens: input.parsedUsage.promptTokens, + completionTokens: input.parsedUsage.completionTokens, + totalTokens: input.parsedUsage.totalTokens, + }; + let estimatedCost = 0; + let billingDetails: unknown = null; + + try { + resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ + site: input.selected.site, + account: input.selected.account, + tokenValue: input.selected.tokenValue, + tokenName: input.selected.tokenName, + modelName: input.modelName, + requestStartedAtMs: input.requestStartedAtMs, + requestEndedAtMs: input.requestStartedAtMs + input.latencyMs, + localLatencyMs: input.latencyMs, + usage: { + promptTokens: input.parsedUsage.promptTokens, + completionTokens: input.parsedUsage.completionTokens, + totalTokens: input.parsedUsage.totalTokens, + }, + }); + const billing = await resolveProxyLogBilling({ + site: input.selected.site, + account: input.selected.account, + modelName: input.modelName, + parsedUsage: input.parsedUsage, + resolvedUsage, + }); + estimatedCost = billing.estimatedCost; + billingDetails = billing.billingDetails; + } catch (error) { + if (!input.bestEffortMetrics) { + throw error; + } + console.error(input.bestEffortMetrics.errorLabel, error); + } + + tokenRouter.recordSuccess( + input.selected.channel.id, + input.latencyMs, + estimatedCost, + input.modelName, + ); + input.recordDownstreamCost?.(estimatedCost); + await input.logSuccess({ + selected: input.selected, + modelRequested: input.requestedModel, + status: 'success', + httpStatus: 200, + latencyMs: input.latencyMs, + errorMessage: null, + retryCount: input.retryCount, + promptTokens: resolvedUsage.promptTokens, + completionTokens: resolvedUsage.completionTokens, + totalTokens: resolvedUsage.totalTokens, + estimatedCost, + billingDetails, + upstreamPath: input.upstreamPath, + }); + + return { + resolvedUsage, + estimatedCost, + billingDetails, + }; +} + export function createSurfaceFailureToolkit(input: { warningScope: SurfaceWarningScope; downstreamPath: string; @@ -193,6 +369,14 @@ export function createSurfaceFailureToolkit(input: { ? { action: 'retry' as const } : null; + const runBestEffort = (label: string, fn: () => Promise) => { + void Promise.resolve() + .then(fn) + .catch((error) => { + console.warn(`[proxy/${input.warningScope}] failed to ${label}`, error); + }); + }; + return { log, async handleUpstreamFailure(args: { @@ -220,19 +404,19 @@ export function createSurfaceFailureToolkit(input: { errorMessage: args.errText, retryCount: args.retryCount, }); - await recordOauthQuotaResetHint({ + runBestEffort('record oauth quota reset hint', () => recordOauthQuotaResetHint({ accountId: args.selected.account.id, statusCode: args.status, errorText: rawErrText, - }); + })); if (isTokenExpiredError({ status: args.status, message: args.errText })) { - await reportTokenExpired({ + runBestEffort('report token expired', () => reportTokenExpired({ accountId: args.selected.account.id, username: args.selected.account.username, siteName: args.selected.site.name, detail: `HTTP ${args.status}`, - }); + })); } if (shouldRetryProxyRequest(args.status, args.errText)) { @@ -240,10 +424,10 @@ export function createSurfaceFailureToolkit(input: { if (retry) return retry; } - await reportProxyAllFailed({ + runBestEffort('report proxy all failed', () => reportProxyAllFailed({ model: args.requestedModel, reason: `upstream returned HTTP ${args.status}`, - }); + })); return { action: 'respond', @@ -293,10 +477,10 @@ export function createSurfaceFailureToolkit(input: { if (retry) return retry; } - await reportProxyAllFailed({ + runBestEffort('report proxy all failed', () => reportProxyAllFailed({ model: args.requestedModel, reason: args.failure.reason, - }); + })); return { action: 'respond', @@ -335,10 +519,10 @@ export function createSurfaceFailureToolkit(input: { const retry = maybeRetry(args.retryCount); if (retry) return retry; - await reportProxyAllFailed({ + runBestEffort('report proxy all failed', () => reportProxyAllFailed({ model: args.requestedModel, reason: args.errorMessage || 'network failure', - }); + })); return { action: 'respond', @@ -374,7 +558,10 @@ export function createSurfaceFailureToolkit(input: { modelName: args.modelName, }); } else { - await tokenRouter.recordFailure(args.selected.channel.id, args.modelName); + await tokenRouter.recordFailure(args.selected.channel.id, { + errorText: errorMessage, + modelName: args.modelName, + }); } await log({ selected: args.selected, diff --git a/src/server/routes/api/accountTokens.architecture.test.ts b/src/server/routes/api/accountTokens.architecture.test.ts new file mode 100644 index 0000000..9cdd600 --- /dev/null +++ b/src/server/routes/api/accountTokens.architecture.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('account token route architecture boundaries', () => { + it('keeps legacy token repair from forcing route rebuilds inline', () => { + const source = readSource('./accountTokens.ts'); + expect(source).not.toContain('rebuildRoutes: true'); + }); +}); diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 1f981fd..ebffde4 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -233,7 +233,6 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise('/api/accounts/:id', async (request) => { const id = parseInt(request.params.id); await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run(); - try { - await rebuildTokenRoutesFromAvailability(); - } catch { } + await rebuildRoutesBestEffort(); return { success: true }; }); @@ -1386,9 +1386,7 @@ export async function accountsRoutes(app: FastifyInstance) { } if (shouldRebuildRoutes) { - try { - await rebuildTokenRoutesFromAvailability(); - } catch { } + await rebuildRoutesBestEffort(); } return { @@ -1596,10 +1594,7 @@ export async function accountsRoutes(app: FastifyInstance) { } } }); - - try { - await rebuildTokenRoutesFromAvailability(); - } catch { } + await rebuildRoutesBestEffort(); return { success: true }; } catch (err: any) { diff --git a/src/server/routes/proxy/architecture-boundaries.test.ts b/src/server/routes/proxy/architecture-boundaries.test.ts index c60c140..28f5e20 100644 --- a/src/server/routes/proxy/architecture-boundaries.test.ts +++ b/src/server/routes/proxy/architecture-boundaries.test.ts @@ -92,6 +92,12 @@ describe('proxy route architecture boundaries', () => { expect(source).not.toContain('normalizeContentText('); }); + it('keeps codex runtime header and prompt-cache derivation inside provider profiles', () => { + const source = readSource('./upstreamEndpoint.ts'); + expect(source).not.toContain('buildCodexRuntimeHeaders('); + expect(source).not.toContain('shouldInjectDerivedPromptCacheKey'); + }); + it('keeps gemini runtime closure in transformer-owned helpers', () => { const source = readSource('./gemini.ts'); const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts'); @@ -139,6 +145,23 @@ describe('proxy route architecture boundaries', () => { expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession('); }); + it('keeps oauth refresh recovery and success bookkeeping behind shared surface helpers', () => { + const chatSurfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts'); + const responsesSurfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts'); + + expect(chatSurfaceSource).toContain('trySurfaceOauthRefreshRecovery('); + expect(chatSurfaceSource).toContain('recordSurfaceSuccess('); + expect(chatSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight('); + expect(chatSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback('); + expect(chatSurfaceSource).not.toContain('resolveProxyLogBilling('); + + expect(responsesSurfaceSource).toContain('trySurfaceOauthRefreshRecovery('); + expect(responsesSurfaceSource).toContain('recordSurfaceSuccess('); + expect(responsesSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight('); + expect(responsesSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback('); + expect(responsesSurfaceSource).not.toContain('resolveProxyLogBilling('); + }); + it('keeps canonical transformer contracts imported from the transformer boundary only', () => { const chatSource = readSource('./chat.ts'); const responsesSource = readSource('./responses.ts'); diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 2441004..9a7ffaf 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -29,10 +29,7 @@ import { } from '../../transformers/shared/endpointCompatibility.js'; import { buildClaudeRuntimeHeaders, - buildCodexRuntimeHeaders, - buildGeminiCliRuntimeHeaders, buildGeminiCliUserAgent, - getInputHeader, headerValueToString, } from '../../proxy-core/providers/headerUtils.js'; export { @@ -1234,7 +1231,10 @@ export function buildUpstreamEndpointRequest(input: { ); const configuredResponsesBody = applyConfiguredPayloadRules(body); - if (providerProfile?.id === 'codex') { + if (sitePlatform === 'codex') { + if (providerProfile?.id !== 'codex') { + throw new Error(`missing codex provider profile for platform: ${sitePlatform}`); + } return providerProfile.prepareRequest({ endpoint: 'responses', modelName: input.modelName, @@ -1254,39 +1254,14 @@ export function buildUpstreamEndpointRequest(input: { }); } - const headers = sitePlatform === 'codex' - ? buildCodexRuntimeHeaders({ - baseHeaders: { - ...commonHeaders, - ...responsesHeaders, - }, - providerHeaders: input.providerHeaders, - explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null, - continuityKey: asTrimmedString(input.codexSessionCacheKey) || null, - }) - : ensureStreamAcceptHeader({ - ...commonHeaders, - ...responsesHeaders, - }, input.stream); - const codexSessionId = sitePlatform === 'codex' - ? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id')) - : null; - const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex' - && !!codexSessionId - && !asTrimmedString((configuredResponsesBody as Record).prompt_cache_key) - && !asTrimmedString(input.codexExplicitSessionId) - && !!asTrimmedString(input.codexSessionCacheKey); - const runtimeBody = shouldInjectDerivedPromptCacheKey - ? { - ...configuredResponsesBody, - prompt_cache_key: codexSessionId, - } - : configuredResponsesBody; - + const headers = ensureStreamAcceptHeader({ + ...commonHeaders, + ...responsesHeaders, + }, input.stream); return { path: resolveEndpointPath('responses'), headers, - body: runtimeBody, + body: configuredResponsesBody, runtime, }; } diff --git a/src/server/services/accountMutationWorkflow.test.ts b/src/server/services/accountMutationWorkflow.test.ts index 221fbdc..9be12b5 100644 --- a/src/server/services/accountMutationWorkflow.test.ts +++ b/src/server/services/accountMutationWorkflow.test.ts @@ -107,6 +107,19 @@ describe('accountMutationWorkflow', () => { expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1); }); + it('only marks refreshedModels when the refresh result explicitly says it refreshed', async () => { + refreshModelsForAccountMock.mockResolvedValue({ accountId: 4, refreshed: false, status: 'skipped' }); + + const { convergeAccountMutation } = await import('./accountMutationWorkflow.js'); + const result = await convergeAccountMutation({ + accountId: 4, + refreshModels: true, + }); + + expect(result.modelRefreshResult).toEqual({ accountId: 4, refreshed: false, status: 'skipped' }); + expect(result.refreshedModels).toBe(false); + }); + it('refreshes model coverage in batches and maps failures', async () => { refreshModelsForAccountMock .mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' }) @@ -129,4 +142,26 @@ describe('accountMutationWorkflow', () => { result: { createdRoutes: 2 }, }); }); + + it('rejects invalid batch sizes before starting the batch loop', async () => { + const { refreshAccountCoverageBatch } = await import('./accountMutationWorkflow.js'); + + await expect(refreshAccountCoverageBatch({ + accountIds: [1, 2], + batchSize: 1.5, + mapFailure: (accountId, errorMessage) => ({ accountId, errorMessage }), + })).rejects.toThrow('batchSize must be a positive integer'); + + expect(refreshModelsForAccountMock).not.toHaveBeenCalled(); + }); + + it('exposes a best-effort route rebuild helper for controller callers', async () => { + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValueOnce({ createdRoutes: 1 }); + + const { rebuildRoutesBestEffort } = await import('./accountMutationWorkflow.js'); + await expect(rebuildRoutesBestEffort()).resolves.toBe(true); + + rebuildTokenRoutesFromAvailabilityMock.mockRejectedValueOnce(new Error('rebuild failed')); + await expect(rebuildRoutesBestEffort()).resolves.toBe(false); + }); }); diff --git a/src/server/services/accountMutationWorkflow.ts b/src/server/services/accountMutationWorkflow.ts index f873261..b4c1575 100644 --- a/src/server/services/accountMutationWorkflow.ts +++ b/src/server/services/accountMutationWorkflow.ts @@ -20,6 +20,15 @@ export type CoverageBatchRebuildResult = | { success: true; result: Awaited> } | { success: false; error: string }; +export async function rebuildRoutesBestEffort(): Promise { + try { + await rebuildTokenRoutesFromAvailability(); + return true; + } catch { + return false; + } +} + export async function convergeAccountMutation(input: { accountId: number; preferredApiToken?: string | null; @@ -110,7 +119,7 @@ export async function convergeAccountMutation(input: { const modelRefreshResult = await runStep(() => refreshModelsForAccount(input.accountId)); if (modelRefreshResult) { result.modelRefreshResult = modelRefreshResult; - result.refreshedModels = true; + result.refreshedModels = modelRefreshResult.refreshed === true; } } @@ -133,6 +142,11 @@ export async function refreshAccountCoverageBatch(input: { refresh: Array; rebuild: CoverageBatchRebuildResult | null; }> { + if (!Number.isInteger(input.batchSize) || input.batchSize <= 0) { + throw new Error('batchSize must be a positive integer'); + } + + const batchSize = input.batchSize; const uniqueAccountIds = Array.from(new Set( input.accountIds.filter((id) => Number.isFinite(id) && id > 0), )); @@ -142,8 +156,8 @@ export async function refreshAccountCoverageBatch(input: { } const refresh: Array = []; - for (let offset = 0; offset < uniqueAccountIds.length; offset += input.batchSize) { - const batch = uniqueAccountIds.slice(offset, offset + input.batchSize); + for (let offset = 0; offset < uniqueAccountIds.length; offset += batchSize) { + const batch = uniqueAccountIds.slice(offset, offset + batchSize); const settled = await Promise.allSettled( batch.map(async (accountId) => refreshModelsForAccount(accountId)), ); diff --git a/src/server/services/platforms/standardApiProvider.test.ts b/src/server/services/platforms/standardApiProvider.test.ts index fe2e04b..f3cfaba 100644 --- a/src/server/services/platforms/standardApiProvider.test.ts +++ b/src/server/services/platforms/standardApiProvider.test.ts @@ -7,6 +7,7 @@ import { class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'test-standard'; + fetchJsonImpl = async () => ({ data: [] as Array<{ id: string }> }); async detect(_url: string): Promise { return false; @@ -15,6 +16,14 @@ class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase { async getModels(_baseUrl: string, _token: string): Promise { return []; } + + protected override async fetchJson(url: string, options?: Parameters[1]): Promise { + return this.fetchJsonImpl(url, options) as Promise; + } + + async fetchModelsForTest(options: Parameters[0]) { + return this.fetchModelsFromStandardEndpoint(options); + } } describe('standardApiProvider helpers', () => { @@ -43,4 +52,33 @@ describe('standardApiProvider helpers', () => { quota: 0, }); }); + + it('does not swallow mapper bugs while still returning empty lists for network failures', async () => { + const adapter = new TestStandardApiProviderAdapter(); + adapter.fetchJsonImpl = async () => ({ data: [{ id: 'gpt-5' }] }); + + await expect(adapter.fetchModelsForTest({ + baseUrl: 'https://api.example.com', + mapResponse: () => { + throw new Error('mapper exploded'); + }, + })).rejects.toThrow('mapper exploded'); + + adapter.fetchJsonImpl = async () => { + throw new Error('network failed'); + }; + + await expect(adapter.fetchModelsForTest({ + baseUrl: 'https://api.example.com', + })).resolves.toEqual([]); + }); + + it('rejects invalid payload shapes instead of silently treating them as no models', async () => { + const adapter = new TestStandardApiProviderAdapter(); + adapter.fetchJsonImpl = async () => ({ data: 'not-an-array' }); + + await expect(adapter.fetchModelsForTest({ + baseUrl: 'https://api.example.com', + })).rejects.toThrow('invalid standard models payload'); + }); }); diff --git a/src/server/services/platforms/standardApiProvider.ts b/src/server/services/platforms/standardApiProvider.ts index 1448168..76c516b 100644 --- a/src/server/services/platforms/standardApiProvider.ts +++ b/src/server/services/platforms/standardApiProvider.ts @@ -60,18 +60,27 @@ export abstract class StandardApiProviderAdapterBase extends BasePlatformAdapter ? options.resolveUrl(normalizedBaseUrl) : resolveVersionedModelsUrl(normalizedBaseUrl); + let payload: any; try { - const payload = await this.fetchJson(url, { + payload = await this.fetchJson(url, { headers: options.headers, }); - const rows = options.mapResponse - ? options.mapResponse(payload) - : (payload?.data || []).map((item: any) => item?.id); - return rows - .map((item) => (typeof item === 'string' ? item.trim() : '')) - .filter((item) => item.length > 0); } catch { return []; } + + const rows = options.mapResponse + ? options.mapResponse(payload) + : Array.isArray(payload?.data) + ? payload.data.map((item: any) => item?.id) + : null; + + if (!Array.isArray(rows)) { + throw new Error('invalid standard models payload'); + } + + return rows + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); } } diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index e06bc31..e767c47 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -34,6 +34,47 @@ describe('responses conversion single source of truth', () => { expect(convertOpenAiBodyToResponsesBodyViaCompatibility).toBe(convertOpenAiBodyToResponsesBody); expect(sanitizeResponsesBodyForProxyViaCompatibility).toBe(sanitizeResponsesBodyForProxy); }); + + it('preserves extra properties when normalizing role-only message items', () => { + expect(normalizeResponsesInputForCompatibility([ + { + role: 'assistant', + content: 'done', + id: 'msg_1', + status: 'completed', + }, + ])).toEqual([ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'done' }], + id: 'msg_1', + status: 'completed', + }, + ]); + }); + + it('filters whitespace-only string entries from normalized responses input arrays', () => { + expect(normalizeResponsesInputForCompatibility([ + 'hello', + ' ', + { + role: 'user', + content: 'world', + }, + ])).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'world' }], + }, + ]); + }); }); describe('sanitizeResponsesBodyForProxy', () => { diff --git a/src/server/transformers/openai/responses/normalization.ts b/src/server/transformers/openai/responses/normalization.ts index d27cfb2..9dc820f 100644 --- a/src/server/transformers/openai/responses/normalization.ts +++ b/src/server/transformers/openai/responses/normalization.ts @@ -136,6 +136,7 @@ export function normalizeResponsesMessageItem(item: Record): Re if (asTrimmedString(item.role)) { return { + ...item, type: 'message', role, content: normalizedContent, @@ -158,13 +159,13 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown } if (Array.isArray(input)) { - return input.map((item) => { + return input.flatMap((item) => { if (typeof item === 'string') { const normalized = item.trim(); - return normalized ? toResponsesInputMessageFromText(normalized) : item; + return normalized ? [toResponsesInputMessageFromText(normalized)] : []; } - if (!isRecord(item)) return item; - return normalizeResponsesMessageItem(item); + if (!isRecord(item)) return [item]; + return [normalizeResponsesMessageItem(item)]; }); } diff --git a/src/shared/conversationFileTypes.js b/src/shared/conversationFileTypes.js index 095cd6d..d0af49c 100644 --- a/src/shared/conversationFileTypes.js +++ b/src/shared/conversationFileTypes.js @@ -17,6 +17,12 @@ function normalizeValue(value) { return (value || '').trim().toLowerCase(); } +function normalizeMimeType(value) { + const normalized = normalizeValue(value); + const [essence] = normalized.split(';'); + return (essence || '').trim(); +} + export function inferConversationFileMimeType(filename) { const normalized = normalizeValue(filename); if (normalized.endsWith('.pdf')) return 'application/pdf'; @@ -41,7 +47,7 @@ export function inferConversationFileMimeType(filename) { } export function resolveConversationFileMimeType(mimeType, filename) { - const normalizedMimeType = normalizeValue(mimeType); + const normalizedMimeType = normalizeMimeType(mimeType); if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) { return normalizedMimeType; } @@ -49,14 +55,14 @@ export function resolveConversationFileMimeType(mimeType, filename) { } export function classifyConversationFileMimeType(mimeType) { - const normalized = normalizeValue(mimeType); + const normalized = normalizeMimeType(mimeType); if (normalized.startsWith('image/')) return 'image'; if (normalized.startsWith('audio/')) return 'audio'; return 'document'; } export function detectConversationFileKind(file) { - const mimeType = normalizeValue(file?.mimeType); + const mimeType = normalizeMimeType(file?.mimeType); if (mimeType) { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('audio/')) return 'audio'; @@ -73,7 +79,7 @@ export function detectConversationFileKind(file) { } export function isSupportedConversationFileMimeType(mimeType) { - const normalized = normalizeValue(mimeType); + const normalized = normalizeMimeType(mimeType); return DOCUMENT_MIME_TYPES.has(normalized) || normalized.startsWith('image/') || normalized.startsWith('audio/'); diff --git a/src/shared/conversationFileTypes.test.ts b/src/shared/conversationFileTypes.test.ts index 740fc70..fef7cb3 100644 --- a/src/shared/conversationFileTypes.test.ts +++ b/src/shared/conversationFileTypes.test.ts @@ -42,4 +42,14 @@ describe('conversationFileTypes', () => { mimeType: 'application/octet-stream', })).toBe('document'); }); + + it('strips MIME parameters before matching support and kind rules', () => { + expect(resolveConversationFileMimeType('text/plain; charset=utf-8', 'notes.txt')).toBe('text/plain'); + expect(classifyConversationFileMimeType('text/plain; charset=utf-8')).toBe('document'); + expect(detectConversationFileKind({ + filename: 'notes.txt', + mimeType: 'text/plain; charset=utf-8', + })).toBe('document'); + expect(isSupportedConversationFileMimeType('text/plain; charset=utf-8')).toBe(true); + }); }); diff --git a/src/shared/tokenRoutePatterns.js b/src/shared/tokenRoutePatterns.js index b35b1b1..99c0ed5 100644 --- a/src/shared/tokenRoutePatterns.js +++ b/src/shared/tokenRoutePatterns.js @@ -86,6 +86,11 @@ function isSafeRegexPatternBody(body) { continue; } if (ch === '\\') { + const next = body[index + 1]; + if (!next) return false; + if (/[a-z]/i.test(next) && next !== 'd') { + return false; + } escaped = true; continue; } @@ -99,10 +104,7 @@ function isSafeRegexPatternBody(body) { } if (ch === '(') { if (body[index + 1] === '?') { - if (body[index + 2] !== ':') return false; - groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); - index += 2; - continue; + return false; } groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); continue; diff --git a/src/shared/tokenRoutePatterns.test.ts b/src/shared/tokenRoutePatterns.test.ts index f5da960..d1adf22 100644 --- a/src/shared/tokenRoutePatterns.test.ts +++ b/src/shared/tokenRoutePatterns.test.ts @@ -21,6 +21,18 @@ describe('token route pattern helpers', () => { expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false); }); + it('rejects regex syntax that the lightweight parser does not implement', async () => { + const { + matchesTokenRouteModelPattern, + parseTokenRouteRegexPattern, + } = await import('./tokenRoutePatterns.js'); + + expect(parseTokenRouteRegexPattern('re:^(?:gpt|claude)-5$').regex).toBeNull(); + expect(matchesTokenRouteModelPattern('gpt-5', 're:^(?:gpt|claude)-5$')).toBe(false); + expect(parseTokenRouteRegexPattern('re:^gpt-\\s+$').regex).toBeNull(); + expect(matchesTokenRouteModelPattern('gpt- ', 're:^gpt-\\s+$')).toBe(false); + }); + it('supports exact, glob, and safe regex route matches', async () => { const { matchesTokenRouteModelPattern } = await import('./tokenRoutePatterns.js'); From 3877f3a3f15f562a28b8d293c72d36dba076fbb0 Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 11:53:59 +0800 Subject: [PATCH 07/24] refactor: centralize oauth identity and route refresh workflow --- src/server/index.ts | 6 +- .../proxy-core/surfaces/geminiSurface.ts | 4 +- .../proxy-core/surfaces/sharedSurface.ts | 4 +- src/server/routes/api/oauth.test.ts | 63 ++++++++++++++ .../routeRefreshWorkflow.architecture.test.ts | 57 +++++++++++++ src/server/routes/api/settings.ts | 4 +- src/server/routes/api/stats.ts | 11 +-- src/server/routes/api/tokens.ts | 10 +-- src/server/routes/proxy/completions.ts | 4 +- src/server/routes/proxy/embeddings.ts | 4 +- src/server/routes/proxy/images.ts | 6 +- src/server/routes/proxy/models.ts | 4 +- src/server/routes/proxy/search.ts | 4 +- src/server/routes/proxy/videos.ts | 4 +- .../services/accountMutationWorkflow.ts | 19 ++--- src/server/services/backgroundTaskService.ts | 13 ++- src/server/services/checkinScheduler.ts | 4 +- .../services/oauth/oauthAccount.test.ts | 37 ++++++++ src/server/services/oauth/oauthAccount.ts | 84 +++++++++++++------ .../services/oauth/oauthIdentityBackfill.ts | 43 ++++++++++ src/server/services/oauth/service.ts | 11 ++- src/server/services/routeRefreshWorkflow.ts | 43 ++++++++++ 22 files changed, 361 insertions(+), 78 deletions(-) create mode 100644 src/server/routes/api/routeRefreshWorkflow.architecture.test.ts create mode 100644 src/server/services/oauth/oauthIdentityBackfill.ts create mode 100644 src/server/services/routeRefreshWorkflow.ts diff --git a/src/server/index.ts b/src/server/index.ts index 5eb80ad..8ee121a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,13 +22,14 @@ import { oauthRoutes } from './routes/api/oauth.js'; import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; -import { rebuildTokenRoutesFromAvailability } from './services/modelService.js'; +import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js'; import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; +import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js'; import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js'; import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js'; import { reloadBackupWebdavScheduler } from './services/backupService.js'; @@ -332,7 +333,8 @@ try { await repairStoredCreatedAtValues(); await migrateSiteApiKeysToAccounts(); await ensureDefaultSitesSeeded(); - await rebuildTokenRoutesFromAvailability(); + await ensureOauthIdentityBackfill(); + await routeRefreshWorkflow.rebuildRoutesOnly(); console.log('Loaded runtime settings overrides'); } catch (error) { diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index f531c3f..b1b4208 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -11,7 +11,7 @@ import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js'; import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; @@ -278,7 +278,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { if (!isDirectGeminiFamilyPlatform(selected.site.platform)) { let models = await readRouteAwareGeminiModels(request); if (models.length <= 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); models = await readRouteAwareGeminiModels(request); } return reply.code(200).send({ models }); diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index e2a41f7..f1f91b9 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -1,4 +1,4 @@ -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import type { SiteProxyConfigLike } from '../../services/siteProxy.js'; @@ -86,7 +86,7 @@ export async function selectSurfaceChannelForAttempt(input: { ); if (!selected && input.retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(input.requestedModel, input.downstreamPolicy); } diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 4229d4d..c6d6350 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -1537,6 +1537,69 @@ describe('oauth routes', { timeout: 15_000 }, () => { }); }); + it('backfills structured oauth identity columns for legacy oauth rows before listing connections', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Legacy Codex', + url: 'https://codex.example.com', + platform: 'codex', + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: 0, + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'legacy-user@example.com', + accessToken: 'legacy-oauth-access-token', + status: 'active', + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountKey: 'legacy-chatgpt-account', + projectId: 'legacy-project', + refreshToken: 'legacy-refresh-token', + modelDiscoveryStatus: 'healthy', + }, + }), + isPinned: false, + sortOrder: 0, + }).returning().get(); + + expect(account.oauthProvider).toBeNull(); + expect(account.oauthAccountKey).toBeNull(); + expect(account.oauthProjectId).toBeNull(); + + const response = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + total: 1, + items: [ + expect.objectContaining({ + provider: 'codex', + accountKey: 'legacy-chatgpt-account', + projectId: 'legacy-project', + username: 'legacy-user@example.com', + }), + ], + }); + + const backfilled = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + + expect(backfilled).toEqual(expect.objectContaining({ + oauthProvider: 'codex', + oauthAccountKey: 'legacy-chatgpt-account', + oauthProjectId: 'legacy-project', + })); + }); + it('rejects malformed manual callback submissions', async () => { const startResponse = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts b/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts new file mode 100644 index 0000000..7d2efd5 --- /dev/null +++ b/src/server/routes/api/routeRefreshWorkflow.architecture.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +function expectNoDirectModelServiceRouteRefresh(source: string): void { + expect(source).not.toMatch(/import\s*\{[^}]*\brefreshModelsAndRebuildRoutes\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m); + expect(source).not.toMatch(/import\s*\{[^}]*\brebuildTokenRoutesFromAvailability\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m); +} + +describe('route refresh workflow architecture boundaries', () => { + it('keeps api controllers on the shared route refresh workflow instead of modelService', () => { + const tokensSource = readSource('./tokens.ts'); + const settingsSource = readSource('./settings.ts'); + const statsSource = readSource('./stats.ts'); + + expect(tokensSource).toContain("from '../../services/routeRefreshWorkflow.js'"); + expect(tokensSource).not.toContain("from '../../services/modelService.js'"); + + expect(settingsSource).toContain("from '../../services/routeRefreshWorkflow.js'"); + expect(settingsSource).not.toContain("from '../../services/modelService.js'"); + + expect(statsSource).toContain("from '../../services/routeRefreshWorkflow.js'"); + expectNoDirectModelServiceRouteRefresh(statsSource); + }); + + it('keeps proxy fallback refreshes and scheduler hooks on the route refresh workflow', () => { + const completionsSource = readSource('../proxy/completions.ts'); + const embeddingsSource = readSource('../proxy/embeddings.ts'); + const imagesSource = readSource('../proxy/images.ts'); + const modelsRouteSource = readSource('../proxy/models.ts'); + const searchSource = readSource('../proxy/search.ts'); + const videosSource = readSource('../proxy/videos.ts'); + const schedulerSource = readSource('../../services/checkinScheduler.ts'); + const oauthServiceSource = readSource('../../services/oauth/service.ts'); + const sharedSurfaceSource = readSource('../../proxy-core/surfaces/sharedSurface.ts'); + const geminiSurfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts'); + + for (const source of [ + completionsSource, + embeddingsSource, + imagesSource, + modelsRouteSource, + searchSource, + videosSource, + schedulerSource, + oauthServiceSource, + sharedSurfaceSource, + geminiSurfaceSource, + ]) { + expect(source).toContain('routeRefreshWorkflow'); + expectNoDirectModelServiceRouteRefresh(source); + } + }); +}); diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index f99f2ba..4b2bda9 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -4,7 +4,7 @@ import { fetch } from 'undici'; import { config } from '../../config.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; import { sendNotification } from '../../services/notifyService.js'; import { @@ -1404,7 +1404,7 @@ export async function settingsRoutes(app: FastifyInstance) { }, failureMessage: (currentTask) => `缓存清理后重建失败:${currentTask.error || 'unknown error'}`, }, - async () => refreshModelsAndRebuildRoutes(), + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), ); return reply.code(202).send({ diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 385f59d..1485e6c 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -1,11 +1,8 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, desc, eq, gte, lt, sql } from 'drizzle-orm'; -import { - refreshModelsForAccount, - refreshModelsAndRebuildRoutes, - rebuildTokenRoutesFromAvailability, -} from '../../services/modelService.js'; +import { refreshModelsForAccount } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { buildModelAnalysis } from '../../services/modelAnalysisService.js'; import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js'; import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js'; @@ -847,7 +844,7 @@ export async function statsRoutes(app: FastifyInstance) { }, failureMessage: (currentTask) => `模型广场刷新失败:${currentTask.error || 'unknown error'}`, }, - async () => refreshModelsAndRebuildRoutes(), + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), ); refreshQueued = !reused; refreshReused = reused; @@ -1390,7 +1387,7 @@ export async function statsRoutes(app: FastifyInstance) { } const refresh = await refreshModelsForAccount(accountId); - const rebuild = rebuildTokenRoutesFromAvailability(); + const rebuild = routeRefreshWorkflow.rebuildRoutesOnly(); return { success: true, refresh, rebuild }; }); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 9b11928..5af88e8 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from 'fastify'; import { and, eq, inArray } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; -import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { ACCOUNT_TOKEN_VALUE_STATUS_READY, isUsableAccountToken, @@ -1170,12 +1170,12 @@ export async function tokensRoutes(app: FastifyInstance) { app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { const body = (request.body || {}) as { refreshModels?: boolean }; if (body.refreshModels === false) { - const rebuild = rebuildTokenRoutesFromAvailability(); + const rebuild = routeRefreshWorkflow.rebuildRoutesOnly(); return { success: true, rebuild }; } if ((request.body as { wait?: boolean } | undefined)?.wait) { - const result = await refreshModelsAndRebuildRoutes(); + const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); return { success: true, ...result }; } @@ -1192,8 +1192,8 @@ export async function tokensRoutes(app: FastifyInstance) { }, failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`, }, - async () => refreshModelsAndRebuildRoutes(), - ); + async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), + ); return reply.code(202).send({ success: true, diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 5d49328..6aca4ed 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; @@ -48,7 +48,7 @@ export async function completionsProxyRoute(app: FastifyInstance) { : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 3741523..1ea22b7 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; @@ -46,7 +46,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 4d12c31..54c8d8f 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { estimateProxyCost } from '../../services/modelPricingService.js'; @@ -43,7 +43,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } @@ -189,7 +189,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } diff --git a/src/server/routes/proxy/models.ts b/src/server/routes/proxy/models.ts index 708a7a3..e9e7aff 100644 --- a/src/server/routes/proxy/models.ts +++ b/src/server/routes/proxy/models.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import { listModelsSurface } from '../../proxy-core/surfaces/modelsSurface.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; @@ -14,7 +14,7 @@ export async function modelsProxyRoute(app: FastifyInstance) { downstreamPolicy, responseFormat: wantsClaudeFormat ? 'claude' : 'openai', tokenRouter, - refreshModelsAndRebuildRoutes, + refreshModelsAndRebuildRoutes: routeRefreshWorkflow.refreshModelsAndRebuildRoutes, isModelAllowed: isModelAllowedByPolicyOrAllowedRoutes, }); }); diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index d91cbd4..a609ff2 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; @@ -71,7 +71,7 @@ export async function searchProxyRoute(app: FastifyInstance) { : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } diff --git a/src/server/routes/proxy/videos.ts b/src/server/routes/proxy/videos.ts index 10e5a96..6c3f218 100644 --- a/src/server/routes/proxy/videos.ts +++ b/src/server/routes/proxy/videos.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; import { estimateProxyCost } from '../../services/modelPricingService.js'; @@ -57,7 +57,7 @@ export async function videosProxyRoute(app: FastifyInstance) { : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); if (!selected && retryCount === 0) { - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); } diff --git a/src/server/services/accountMutationWorkflow.ts b/src/server/services/accountMutationWorkflow.ts index b4c1575..7749ab5 100644 --- a/src/server/services/accountMutationWorkflow.ts +++ b/src/server/services/accountMutationWorkflow.ts @@ -4,10 +4,10 @@ import { syncTokensFromUpstream, } from './accountTokenService.js'; import { - rebuildTokenRoutesFromAvailability, refreshModelsForAccount, type ModelRefreshResult, } from './modelService.js'; +import * as routeRefreshWorkflow from './routeRefreshWorkflow.js'; type UpstreamTokenLike = { name?: string | null; @@ -17,16 +17,11 @@ type UpstreamTokenLike = { }; export type CoverageBatchRebuildResult = - | { success: true; result: Awaited> } + | { success: true; result: Awaited> } | { success: false; error: string }; export async function rebuildRoutesBestEffort(): Promise { - try { - await rebuildTokenRoutesFromAvailability(); - return true; - } catch { - return false; - } + return routeRefreshWorkflow.rebuildRoutesBestEffort(); } export async function convergeAccountMutation(input: { @@ -47,7 +42,7 @@ export async function convergeAccountMutation(input: { rebuiltRoutes: boolean; balanceResult: Awaited> | null; modelRefreshResult: ModelRefreshResult | null; - rebuildResult: Awaited> | null; + rebuildResult: Awaited> | null; }> { const result = { defaultTokenId: null as number | null, @@ -57,7 +52,7 @@ export async function convergeAccountMutation(input: { rebuiltRoutes: false, balanceResult: null as Awaited> | null, modelRefreshResult: null as ModelRefreshResult | null, - rebuildResult: null as Awaited> | null, + rebuildResult: null as Awaited> | null, }; const runStep = async (fn: () => Promise): Promise => { @@ -124,7 +119,7 @@ export async function convergeAccountMutation(input: { } if (input.rebuildRoutes) { - const rebuildResult = await runStep(() => rebuildTokenRoutesFromAvailability()); + const rebuildResult = await runStep(() => routeRefreshWorkflow.rebuildRoutesOnly()); if (rebuildResult) { result.rebuildResult = rebuildResult; result.rebuiltRoutes = true; @@ -180,7 +175,7 @@ export async function refreshAccountCoverageBatch(input: { refresh, rebuild: { success: true, - result: await rebuildTokenRoutesFromAvailability(), + result: await routeRefreshWorkflow.rebuildRoutesOnly(), }, }; } catch (error) { diff --git a/src/server/services/backgroundTaskService.ts b/src/server/services/backgroundTaskService.ts index 140554c..04e2740 100644 --- a/src/server/services/backgroundTaskService.ts +++ b/src/server/services/backgroundTaskService.ts @@ -40,6 +40,7 @@ const TASK_CLEANUP_INTERVAL_MS = 60 * 1000; const tasks = new Map(); const dedupeTaskIds = new Map(); +let cleanupTimer: ReturnType | null = null; function nowIso() { return new Date().toISOString(); @@ -157,13 +158,17 @@ function cleanupExpiredTasks() { } } -const cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS); -cleanupTimer.unref?.(); +function ensureCleanupTimer() { + if (cleanupTimer) return; + cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS); + cleanupTimer.unref?.(); +} export function startBackgroundTask( options: BackgroundTaskStartOptions, runner: () => Promise, ): { task: BackgroundTask; reused: boolean } { + ensureCleanupTimer(); const dedupeKey = options.dedupeKey?.trim() || ''; if (dedupeKey) { const existingTaskId = dedupeTaskIds.get(dedupeKey); @@ -241,4 +246,8 @@ export function summarizeCheckinResults(results: Array<{ result?: any }>): { tot export function __resetBackgroundTasksForTests() { tasks.clear(); dedupeTaskIds.clear(); + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + } } diff --git a/src/server/services/checkinScheduler.ts b/src/server/services/checkinScheduler.ts index e758ec1..09e9e2d 100644 --- a/src/server/services/checkinScheduler.ts +++ b/src/server/services/checkinScheduler.ts @@ -4,7 +4,7 @@ import { config } from '../config.js'; import { db, schema } from '../db/index.js'; import { refreshAllBalances } from './balanceService.js'; import { checkinAll } from './checkinService.js'; -import { refreshModelsAndRebuildRoutes } from './modelService.js'; +import * as routeRefreshWorkflow from './routeRefreshWorkflow.js'; import { sendNotification } from './notifyService.js'; import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js'; import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js'; @@ -162,7 +162,7 @@ function createBalanceTask(cronExpr: string) { console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`); try { await refreshAllBalances(); - await refreshModelsAndRebuildRoutes(); + await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); console.log('[Scheduler] Balance refresh complete'); } catch (err) { console.error('[Scheduler] Balance refresh error:', err); diff --git a/src/server/services/oauth/oauthAccount.test.ts b/src/server/services/oauth/oauthAccount.test.ts index 6ca83f3..904c55a 100644 --- a/src/server/services/oauth/oauthAccount.test.ts +++ b/src/server/services/oauth/oauthAccount.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + buildOauthIdentityBackfillPatch, buildOauthInfoFromAccount, buildStoredOauthStateFromAccount, getOauthInfoFromAccount, @@ -133,4 +134,40 @@ describe('oauth account identity helpers', () => { quota: { status: 'supported' }, }); }); + + it('builds a structured identity backfill patch from legacy oauth metadata only for missing columns', () => { + expect(buildOauthIdentityBackfillPatch({ + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + extraConfig: JSON.stringify({ + oauth: { + provider: 'codex', + accountKey: 'legacy-account', + projectId: 'legacy-project', + refreshToken: 'refresh-token', + }, + }), + })).toEqual({ + oauthProvider: 'codex', + oauthAccountKey: 'legacy-account', + oauthProjectId: 'legacy-project', + }); + + expect(buildOauthIdentityBackfillPatch({ + oauthProvider: 'codex', + oauthAccountKey: 'structured-account', + oauthProjectId: null, + extraConfig: JSON.stringify({ + oauth: { + provider: 'legacy-provider-ignored', + accountKey: 'legacy-account-ignored', + projectId: 'legacy-project', + refreshToken: 'refresh-token', + }, + }), + })).toEqual({ + oauthProjectId: 'legacy-project', + }); + }); }); diff --git a/src/server/services/oauth/oauthAccount.ts b/src/server/services/oauth/oauthAccount.ts index 79f3a86..77710b8 100644 --- a/src/server/services/oauth/oauthAccount.ts +++ b/src/server/services/oauth/oauthAccount.ts @@ -52,6 +52,8 @@ type OauthIdentityCarrier = { oauthProjectId?: string | null; }; +type StoredOauthIdentity = Pick; + function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig { if (!extraConfig) return {}; try { @@ -113,17 +115,26 @@ function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | und return undefined; } -function parseStoredOauthState(extraConfig?: string | null): Partial | null { +function parseStoredOauthIdentity(extraConfig?: string | null): StoredOauthIdentity | null { const parsed = parseExtraConfig(extraConfig).oauth; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId); + const provider = asTrimmedString(parsed.provider); + if (!provider) return null; return { - provider: asTrimmedString(parsed.provider), + provider, accountId: asTrimmedString(parsed.accountId) || accountKey, accountKey, + projectId: asTrimmedString(parsed.projectId), + }; +} + +function parseStoredOauthRuntimeState(extraConfig?: string | null): Partial | null { + const parsed = parseExtraConfig(extraConfig).oauth; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return { email: asTrimmedString(parsed.email), planType: asTrimmedString(parsed.planType), - projectId: asTrimmedString(parsed.projectId), tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt), refreshToken: asTrimmedString(parsed.refreshToken), idToken: asTrimmedString(parsed.idToken), @@ -137,45 +148,68 @@ function parseStoredOauthState(extraConfig?: string | null): Partial } export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null { - const parsed = parseStoredOauthState(extraConfig); - const provider = parsed?.provider; + const identity = parseStoredOauthIdentity(extraConfig); + const runtime = parseStoredOauthRuntimeState(extraConfig); + const provider = identity?.provider; if (!provider) return null; return { provider, - accountId: parsed.accountId || parsed.accountKey, - accountKey: parsed.accountKey, - email: parsed.email, - planType: parsed.planType, - projectId: parsed.projectId, - tokenExpiresAt: parsed.tokenExpiresAt, - refreshToken: parsed.refreshToken, - idToken: parsed.idToken, - providerData: parsed.providerData, - quota: parsed.quota, - modelDiscoveryStatus: parsed.modelDiscoveryStatus, - lastModelSyncAt: parsed.lastModelSyncAt, - lastModelSyncError: parsed.lastModelSyncError, - lastDiscoveredModels: parsed.lastDiscoveredModels, + accountId: identity?.accountId || identity?.accountKey, + accountKey: identity?.accountKey, + projectId: identity?.projectId, + email: runtime?.email, + planType: runtime?.planType, + tokenExpiresAt: runtime?.tokenExpiresAt, + refreshToken: runtime?.refreshToken, + idToken: runtime?.idToken, + providerData: runtime?.providerData, + quota: runtime?.quota, + modelDiscoveryStatus: runtime?.modelDiscoveryStatus, + lastModelSyncAt: runtime?.lastModelSyncAt, + lastModelSyncError: runtime?.lastModelSyncError, + lastDiscoveredModels: runtime?.lastDiscoveredModels, }; } export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null): OauthInfo | null { if (!account) return null; - const stored = parseStoredOauthState(account.extraConfig); - const provider = asTrimmedString(account.oauthProvider) || stored?.provider; + const storedIdentity = parseStoredOauthIdentity(account.extraConfig); + const storedRuntime = parseStoredOauthRuntimeState(account.extraConfig); + const provider = asTrimmedString(account.oauthProvider) || storedIdentity?.provider; if (!provider) return null; const structuredAccountKey = asTrimmedString(account.oauthAccountKey); - const accountKey = structuredAccountKey || stored?.accountKey || stored?.accountId; - const projectId = asTrimmedString(account.oauthProjectId) || stored?.projectId; + const accountKey = structuredAccountKey || storedIdentity?.accountKey || storedIdentity?.accountId; + const projectId = asTrimmedString(account.oauthProjectId) || storedIdentity?.projectId; return { - ...(stored || {}), + ...(storedRuntime || {}), provider, - accountId: structuredAccountKey || stored?.accountId || accountKey, + accountId: structuredAccountKey || storedIdentity?.accountId || accountKey, accountKey, projectId, }; } +export function buildOauthIdentityBackfillPatch( + account?: OauthIdentityCarrier | null, +): Partial> | null { + if (!account) return null; + const legacyIdentity = parseStoredOauthIdentity(account.extraConfig); + if (!legacyIdentity?.provider) return null; + + const patch: Partial> = {}; + if (!asTrimmedString(account.oauthProvider)) { + patch.oauthProvider = legacyIdentity.provider; + } + if (!asTrimmedString(account.oauthAccountKey) && (legacyIdentity.accountKey || legacyIdentity.accountId)) { + patch.oauthAccountKey = legacyIdentity.accountKey || legacyIdentity.accountId; + } + if (!asTrimmedString(account.oauthProjectId) && legacyIdentity.projectId) { + patch.oauthProjectId = legacyIdentity.projectId; + } + + return Object.keys(patch).length > 0 ? patch : null; +} + export function buildOauthInfo( extraConfig?: string | null, patch: Partial = {}, diff --git a/src/server/services/oauth/oauthIdentityBackfill.ts b/src/server/services/oauth/oauthIdentityBackfill.ts new file mode 100644 index 0000000..9f5114e --- /dev/null +++ b/src/server/services/oauth/oauthIdentityBackfill.ts @@ -0,0 +1,43 @@ +import { eq, isNotNull, isNull, or } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { buildOauthIdentityBackfillPatch } from './oauthAccount.js'; + +let inFlightOauthIdentityBackfill: Promise | null = null; + +async function runOauthIdentityBackfill(): Promise { + const rows = await db.select().from(schema.accounts) + .where(or( + isNotNull(schema.accounts.extraConfig), + isNull(schema.accounts.oauthProvider), + )) + .all(); + + let updated = 0; + for (const row of rows) { + const patch = buildOauthIdentityBackfillPatch(row); + if (!patch) continue; + await db.update(schema.accounts).set({ + ...patch, + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, row.id)).run(); + updated += 1; + } + + return updated; +} + +export async function ensureOauthIdentityBackfill(): Promise { + if (inFlightOauthIdentityBackfill) { + return inFlightOauthIdentityBackfill; + } + + inFlightOauthIdentityBackfill = (async () => { + try { + return await runOauthIdentityBackfill(); + } finally { + inFlightOauthIdentityBackfill = null; + } + })(); + + return inFlightOauthIdentityBackfill; +} diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts index daf22b4..0294c01 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -1,7 +1,8 @@ import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import { mergeAccountExtraConfig } from '../accountExtraConfig.js'; -import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js'; +import { refreshModelsForAccount } from '../modelService.js'; +import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js'; import { createOauthSession, getOauthSession, @@ -22,6 +23,7 @@ import { getOauthInfoFromAccount, } from './oauthAccount.js'; import { buildCodexOauthInfo } from './codexAccount.js'; +import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js'; import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js'; type OAuthProviderMetadata = ReturnType[number]; @@ -404,7 +406,7 @@ export async function handleOauthCallback(input: { updatedAt: previousAccount.updatedAt, }).where(eq(schema.accounts.id, previousAccount.id)).run(); } - await rebuildTokenRoutesFromAvailability(); + await routeRefreshWorkflow.rebuildRoutesOnly(); const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`; markOauthSessionError(input.state, errorMessage); throw new Error(errorMessage); @@ -417,7 +419,7 @@ export async function handleOauthCallback(input: { }).where(eq(schema.accounts.id, account.id)).run(); } - await rebuildTokenRoutesFromAvailability(); + await routeRefreshWorkflow.rebuildRoutesOnly(); markOauthSessionSuccess(input.state, { accountId: account.id, siteId: site.id, @@ -464,6 +466,7 @@ export async function listOauthConnections(options: { limit?: number; offset?: number; } = {}) { + await ensureOauthIdentityBackfill(); const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50))); const offset = Math.max(0, Math.trunc(options.offset ?? 0)); @@ -565,7 +568,7 @@ export async function deleteOauthConnection(accountId: number) { throw new Error('account is not managed by oauth'); } await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run(); - await rebuildTokenRoutesFromAvailability(); + await routeRefreshWorkflow.rebuildRoutesOnly(); return { success: true }; } diff --git a/src/server/services/routeRefreshWorkflow.ts b/src/server/services/routeRefreshWorkflow.ts new file mode 100644 index 0000000..32c9bd6 --- /dev/null +++ b/src/server/services/routeRefreshWorkflow.ts @@ -0,0 +1,43 @@ +import { startBackgroundTask } from './backgroundTaskService.js'; +import { + rebuildTokenRoutesFromAvailability, + refreshModelsAndRebuildRoutes as refreshModelsAndRebuildRoutesViaModelService, +} from './modelService.js'; + +export async function rebuildRoutesOnly() { + return rebuildTokenRoutesFromAvailability(); +} + +export async function rebuildRoutesBestEffort() { + try { + await rebuildRoutesOnly(); + return true; + } catch { + return false; + } +} + +export async function refreshModelsAndRebuildRoutes() { + return refreshModelsAndRebuildRoutesViaModelService(); +} + +export function queueRefreshModelsAndRebuildRoutesTask(input: { + type: string; + title: string; + dedupeKey?: string; + notifyOnFailure?: boolean; + successMessage: (currentTask: { result?: unknown }) => string; + failureMessage: (currentTask: { error?: string | null }) => string; +}) { + return startBackgroundTask( + { + type: input.type, + title: input.title, + dedupeKey: input.dedupeKey || 'refresh-models-and-rebuild-routes', + notifyOnFailure: input.notifyOnFailure ?? true, + successMessage: input.successMessage, + failureMessage: input.failureMessage, + }, + async () => refreshModelsAndRebuildRoutes(), + ); +} From 50c34b8866315c3d9d7021c59d9d07cea6232abf Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 12:02:44 +0800 Subject: [PATCH 08/24] refactor: keep downstream client metadata on cli profiles --- .../cliProfiles/claudeCodeProfile.ts | 3 + .../proxy-core/cliProfiles/codexProfile.ts | 12 +++ .../cliProfiles/geminiCliProfile.ts | 3 + .../proxy-core/cliProfiles/registry.test.ts | 9 +++ src/server/proxy-core/cliProfiles/types.ts | 5 ++ ...wnstreamClientContext.architecture.test.ts | 17 ++++ .../proxy/downstreamClientContext.test.ts | 8 +- .../routes/proxy/downstreamClientContext.ts | 80 +++++-------------- 8 files changed, 72 insertions(+), 65 deletions(-) create mode 100644 src/server/routes/proxy/downstreamClientContext.architecture.test.ts diff --git a/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts b/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts index 87b8c8a..0f67218 100644 --- a/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts +++ b/src/server/proxy-core/cliProfiles/claudeCodeProfile.ts @@ -48,6 +48,9 @@ export const claudeCodeCliProfile: CliProfileDefinition = { id: 'claude_code', sessionId, traceHint: sessionId, + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', }; }, }; diff --git a/src/server/proxy-core/cliProfiles/codexProfile.ts b/src/server/proxy-core/cliProfiles/codexProfile.ts index 8c6fe62..291f34a 100644 --- a/src/server/proxy-core/cliProfiles/codexProfile.ts +++ b/src/server/proxy-core/cliProfiles/codexProfile.ts @@ -189,9 +189,21 @@ export const codexCliProfile: CliProfileDefinition = { if (!isCodexRequest(input)) return null; const sessionId = getCodexSessionId(input.headers) || undefined; + const clientApp = detectCodexOfficialClientApp(input.headers); return { id: 'codex', ...(sessionId ? { sessionId, traceHint: sessionId } : {}), + ...(clientApp + ? { + clientAppId: clientApp.clientAppId, + clientAppName: clientApp.clientAppName, + clientConfidence: 'exact' as const, + } + : { + clientAppId: 'codex', + clientAppName: 'Codex', + clientConfidence: 'heuristic' as const, + }), }; }, }; diff --git a/src/server/proxy-core/cliProfiles/geminiCliProfile.ts b/src/server/proxy-core/cliProfiles/geminiCliProfile.ts index cf27c7c..fc9c7fc 100644 --- a/src/server/proxy-core/cliProfiles/geminiCliProfile.ts +++ b/src/server/proxy-core/cliProfiles/geminiCliProfile.ts @@ -32,6 +32,9 @@ export const geminiCliProfile: CliProfileDefinition = { if (input.body !== undefined && !hasGeminiCliBodyShape(input.body)) return null; return { id: 'gemini_cli', + clientAppId: 'gemini_cli', + clientAppName: 'Gemini CLI', + clientConfidence: 'exact', }; }, }; diff --git a/src/server/proxy-core/cliProfiles/registry.test.ts b/src/server/proxy-core/cliProfiles/registry.test.ts index 519daac..89b66a2 100644 --- a/src/server/proxy-core/cliProfiles/registry.test.ts +++ b/src/server/proxy-core/cliProfiles/registry.test.ts @@ -14,6 +14,9 @@ describe('detectCliProfile', () => { id: 'codex', sessionId: 'codex-session-123', traceHint: 'codex-session-123', + clientAppId: 'codex_cli_rs', + clientAppName: 'Codex CLI', + clientConfidence: 'exact', capabilities: { supportsResponsesCompact: true, supportsResponsesWebsocketIncremental: true, @@ -79,6 +82,9 @@ describe('detectCliProfile', () => { id: 'claude_code', sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4', + clientAppId: 'claude_code', + clientAppName: 'Claude Code', + clientConfidence: 'exact', capabilities: { supportsResponsesCompact: false, supportsResponsesWebsocketIncremental: false, @@ -98,6 +104,9 @@ describe('detectCliProfile', () => { }, })).toEqual({ id: 'gemini_cli', + clientAppId: 'gemini_cli', + clientAppName: 'Gemini CLI', + clientConfidence: 'exact', capabilities: { supportsResponsesCompact: false, supportsResponsesWebsocketIncremental: false, diff --git a/src/server/proxy-core/cliProfiles/types.ts b/src/server/proxy-core/cliProfiles/types.ts index 6e43479..5f95c67 100644 --- a/src/server/proxy-core/cliProfiles/types.ts +++ b/src/server/proxy-core/cliProfiles/types.ts @@ -12,6 +12,8 @@ export type CliProfileCapabilities = { echoesTurnState: boolean; }; +export type CliProfileClientConfidence = 'exact' | 'heuristic'; + export type DetectCliProfileInput = { downstreamPath: string; headers?: Record; @@ -22,6 +24,9 @@ export type DetectedCliProfile = { id: CliProfileId; sessionId?: string; traceHint?: string; + clientAppId?: string; + clientAppName?: string; + clientConfidence?: CliProfileClientConfidence; capabilities: CliProfileCapabilities; }; diff --git a/src/server/routes/proxy/downstreamClientContext.architecture.test.ts b/src/server/routes/proxy/downstreamClientContext.architecture.test.ts new file mode 100644 index 0000000..d562f67 --- /dev/null +++ b/src/server/routes/proxy/downstreamClientContext.architecture.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('downstreamClientContext architecture boundaries', () => { + it('keeps protocol-level client metadata on cli profiles instead of importing individual profile helpers', () => { + const source = readSource('./downstreamClientContext.ts'); + + expect(source).toContain("from '../../proxy-core/cliProfiles/registry.js'"); + expect(source).toContain("from '../../proxy-core/cliProfiles/types.js'"); + expect(source).not.toContain("from '../../proxy-core/cliProfiles/codexProfile.js'"); + expect(source).not.toContain("from '../../proxy-core/cliProfiles/claudeCodeProfile.js'"); + }); +}); diff --git a/src/server/routes/proxy/downstreamClientContext.test.ts b/src/server/routes/proxy/downstreamClientContext.test.ts index 0cd835b..9b2b26e 100644 --- a/src/server/routes/proxy/downstreamClientContext.test.ts +++ b/src/server/routes/proxy/downstreamClientContext.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { - detectDownstreamClientContext, - extractClaudeCodeSessionId, - isCodexResponsesSurface, -} from './downstreamClientContext.js'; +import { extractClaudeCodeSessionId } from '../../proxy-core/cliProfiles/claudeCodeProfile.js'; +import { isCodexResponsesSurface } from '../../proxy-core/cliProfiles/codexProfile.js'; +import { detectDownstreamClientContext } from './downstreamClientContext.js'; describe('extractClaudeCodeSessionId', () => { it('extracts session uuid from axonhub-compatible Claude Code user ids', () => { diff --git a/src/server/routes/proxy/downstreamClientContext.ts b/src/server/routes/proxy/downstreamClientContext.ts index 042075f..56fa75e 100644 --- a/src/server/routes/proxy/downstreamClientContext.ts +++ b/src/server/routes/proxy/downstreamClientContext.ts @@ -1,15 +1,11 @@ -import { extractClaudeCodeSessionId as extractClaudeCodeSessionIdViaProfile } from '../../proxy-core/cliProfiles/claudeCodeProfile.js'; -import { - detectCliProfile, -} from '../../proxy-core/cliProfiles/registry.js'; -import { - detectCodexOfficialClientApp as detectCodexOfficialClientAppViaProfile, - isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile, -} from '../../proxy-core/cliProfiles/codexProfile.js'; -import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js'; +import { detectCliProfile } from '../../proxy-core/cliProfiles/registry.js'; +import type { + CliProfileClientConfidence, + CliProfileId, +} from '../../proxy-core/cliProfiles/types.js'; export type DownstreamClientKind = CliProfileId; -export type DownstreamClientConfidence = 'exact' | 'heuristic'; +export type DownstreamClientConfidence = CliProfileClientConfidence; export type DownstreamClientContext = { clientKind: DownstreamClientKind; @@ -40,7 +36,7 @@ type DownstreamClientFingerprintRule = { match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null; }; -type DownstreamProtocolClientApp = { +type DownstreamResolvedClientApp = { clientAppId: string; clientAppName: string; clientConfidence: DownstreamClientConfidence; @@ -199,7 +195,7 @@ function detectDownstreamClientFingerprint(input: { }; } -function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): DownstreamProtocolClientApp | null { +function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): DownstreamResolvedClientApp | null { for (const value of headers['x-openai-client-user-agent'] || []) { const clientAppName = parseExplicitClientSelfReportValue(value); if (!clientAppName) continue; @@ -223,49 +219,6 @@ function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): Downs return null; } -function detectProtocolClientApp(input: { - clientKind: DownstreamClientKind; - headers?: Record; -}): DownstreamProtocolClientApp | null { - switch (input.clientKind) { - case 'claude_code': - return { - clientAppId: 'claude_code', - clientAppName: 'Claude Code', - clientConfidence: 'exact', - }; - case 'gemini_cli': - return { - clientAppId: 'gemini_cli', - clientAppName: 'Gemini CLI', - clientConfidence: 'exact', - }; - case 'codex': { - const clientApp = detectCodexOfficialClientAppViaProfile(input.headers); - return clientApp - ? { - ...clientApp, - clientConfidence: 'exact' as const, - } - : { - clientAppId: 'codex', - clientAppName: 'Codex', - clientConfidence: 'heuristic', - }; - } - default: - return null; - } -} - -export function isCodexResponsesSurface(headers?: Record): boolean { - return isCodexResponsesSurfaceViaProfile(headers); -} - -export function extractClaudeCodeSessionId(userId: string): string | null { - return extractClaudeCodeSessionIdViaProfile(userId); -} - export function detectDownstreamClientContext(input: { downstreamPath: string; headers?: Record; @@ -275,14 +228,21 @@ export function detectDownstreamClientContext(input: { const normalizedHeaders = normalizeHeaders(input.headers); const explicitSelfReport = detectExplicitClientSelfReport(normalizedHeaders); const fingerprint = detectDownstreamClientFingerprint(input); - const protocolClientApp = fingerprint || explicitSelfReport ? null : detectProtocolClientApp({ - clientKind: detected.id, - headers: input.headers, - }); + const profileClientApp = fingerprint || explicitSelfReport + ? null + : ( + detected.clientAppId && detected.clientAppName + ? { + clientAppId: detected.clientAppId, + clientAppName: detected.clientAppName, + ...(detected.clientConfidence ? { clientConfidence: detected.clientConfidence } : {}), + } + : null + ); return { clientKind: detected.id, ...(detected.sessionId ? { sessionId: detected.sessionId } : {}), ...(detected.traceHint ? { traceHint: detected.traceHint } : {}), - ...(explicitSelfReport || fingerprint || protocolClientApp || {}), + ...(explicitSelfReport || fingerprint || profileClientApp || {}), }; } From 86518202fabf2bd79431f9337308ba2765f757f9 Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 12:14:22 +0800 Subject: [PATCH 09/24] refactor: move platform discovery out of model service --- ...odelService.discovery.architecture.test.ts | 18 ++ src/server/services/modelService.ts | 234 +--------------- .../services/platformDiscoveryRegistry.ts | 252 ++++++++++++++++++ 3 files changed, 276 insertions(+), 228 deletions(-) create mode 100644 src/server/services/modelService.discovery.architecture.test.ts create mode 100644 src/server/services/platformDiscoveryRegistry.ts diff --git a/src/server/services/modelService.discovery.architecture.test.ts b/src/server/services/modelService.discovery.architecture.test.ts new file mode 100644 index 0000000..f7f16a7 --- /dev/null +++ b/src/server/services/modelService.discovery.architecture.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('modelService discovery architecture boundaries', () => { + it('keeps platform-specific oauth discovery HTTP logic in a dedicated discovery registry', () => { + const source = readSource('./modelService.ts'); + + expect(source).toContain("from './platformDiscoveryRegistry.js'"); + expect(source).not.toContain('function discoverCodexModelsFromCloud'); + expect(source).not.toContain('function discoverClaudeModelsFromCloud'); + expect(source).not.toContain('function validateGeminiCliOauthConnection'); + expect(source).not.toContain('function discoverAntigravityModelsFromCloud'); + }); +}); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 9c2a18a..4a4f9df 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -1,5 +1,4 @@ import { and, eq } from 'drizzle-orm'; -import { fetch } from 'undici'; import { db, schema } from '../db/index.js'; import { getAdapter } from './platforms/index.js'; import { @@ -20,27 +19,20 @@ import { import { invalidateTokenRouterCache } from './tokenRouter.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; -import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; +import { withAccountProxyOverride } from './siteProxy.js'; import { isCodexPlatform } from './oauth/codexAccount.js'; import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js'; -import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js'; import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js'; import { - ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, - ANTIGRAVITY_MODELS_USER_AGENT, - ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, - ANTIGRAVITY_UPSTREAM_BASE_URL, -} from './oauth/antigravityProvider.js'; -import { - GEMINI_CLI_GOOGLE_API_CLIENT, - GEMINI_CLI_REQUIRED_SERVICE, - GEMINI_CLI_USER_AGENT, -} from './oauth/geminiCliProvider.js'; + discoverAntigravityModelsFromCloud, + discoverClaudeModelsFromCloud, + discoverCodexModelsFromCloud, + validateGeminiCliOauthConnection, +} from './platformDiscoveryRegistry.js'; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const MODEL_REFRESH_BATCH_SIZE = 3; -const CODEX_MODELS_CLIENT_VERSION = '1.0.0'; const GEMINI_CLI_STATIC_MODELS = [ 'gemini-2.5-pro', 'gemini-2.5-flash', @@ -152,88 +144,6 @@ function normalizeModels(models: string[]): string[] { return normalizedModels; } -function buildCodexModelsEndpoint(baseUrl: string): string { - const normalized = (baseUrl || '').replace(/\/+$/, ''); - return `${normalized}/models?client_version=${encodeURIComponent(CODEX_MODELS_CLIENT_VERSION)}`; -} - -function extractCodexModelIds(payload: unknown): string[] { - const collection = (() => { - if (Array.isArray(payload)) return payload; - if (!payload || typeof payload !== 'object') return []; - const record = payload as Record; - if (Array.isArray(record.models)) return record.models; - if (Array.isArray(record.data)) return record.data; - if (Array.isArray(record.items)) return record.items; - return []; - })(); - - return collection.flatMap((item) => { - if (typeof item === 'string') return [item]; - if (!item || typeof item !== 'object' || Array.isArray(item)) return []; - const record = item as Record; - const id = typeof record.id === 'string' - ? record.id - : (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : '')); - return id ? [id] : []; - }); -} - -function extractClaudeModelIds(payload: unknown): string[] { - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []; - const record = payload as { data?: unknown }; - if (!Array.isArray(record.data)) return []; - - return record.data.flatMap((item) => { - if (!item || typeof item !== 'object' || Array.isArray(item)) return []; - const value = item as { id?: unknown; name?: unknown }; - const id = typeof value.id === 'string' - ? value.id.trim() - : (typeof value.name === 'string' ? value.name.trim() : ''); - return id ? [id] : []; - }); -} - -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function extractAntigravityModelIds(payload: unknown): string[] { - if (!payload || typeof payload !== 'object') return []; - const record = payload as { models?: unknown }; - if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) { - return Object.keys(record.models).map((name) => name.trim()).filter(Boolean); - } - if (!Array.isArray(record.models)) return []; - return record.models.flatMap((item) => { - if (typeof item === 'string') { - const trimmed = item.trim(); - return trimmed ? [trimmed] : []; - } - if (!item || typeof item !== 'object' || Array.isArray(item)) return []; - const value = item as { id?: unknown; name?: unknown }; - const id = typeof value.id === 'string' - ? value.id.trim() - : (typeof value.name === 'string' ? value.name.trim() : ''); - return id ? [id] : []; - }); -} - -function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] { - const seen = new Set(); - return [ - siteUrl, - ANTIGRAVITY_UPSTREAM_BASE_URL, - ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, - ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, - ].flatMap((candidate) => { - const normalized = normalizeBaseUrl(candidate); - if (!normalized || seen.has(normalized)) return []; - seen.add(normalized); - return [normalized]; - }); -} - async function updateOauthModelDiscoveryState(input: { account: typeof schema.accounts.$inferSelect; checkedAt: string; @@ -259,138 +169,6 @@ async function updateOauthModelDiscoveryState(input: { return extraConfig; } -async function discoverCodexModelsFromCloud(input: { - site: typeof schema.sites.$inferSelect; - account: typeof schema.accounts.$inferSelect; -}): Promise { - const accessToken = (input.account.accessToken || '').trim(); - if (!accessToken) { - throw new Error('codex oauth access token missing'); - } - const oauth = getOauthInfoFromAccount(input.account); - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - Originator: 'codex_cli_rs', - }; - if (oauth?.accountId) { - headers['Chatgpt-Account-Id'] = oauth.accountId; - } - - const response = await fetch( - buildCodexModelsEndpoint(input.site.url), - withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }), - ); - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`); - } - return normalizeModels(extractCodexModelIds(await response.json())); -} - -async function discoverClaudeModelsFromCloud(input: { - site: typeof schema.sites.$inferSelect; - account: typeof schema.accounts.$inferSelect; -}): Promise { - const accessToken = (input.account.accessToken || '').trim(); - if (!accessToken) { - throw new Error('claude oauth access token missing'); - } - const response = await fetch( - `${input.site.url.replace(/\/+$/, '')}/v1/models`, - withSiteRecordProxyRequestInit(input.site, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, - }, - }), - ); - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`); - } - return normalizeModels(extractClaudeModelIds(await response.json())); -} - -async function validateGeminiCliOauthConnection(input: { - account: typeof schema.accounts.$inferSelect; -}): Promise { - const accessToken = (input.account.accessToken || '').trim(); - if (!accessToken) { - throw new Error('gemini cli oauth access token missing'); - } - const oauth = getOauthInfoFromAccount(input.account); - const projectId = (oauth?.projectId || '').trim(); - if (!projectId) { - throw new Error('gemini cli oauth project id missing'); - } - const response = await fetch( - `https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, - withExplicitProxyRequestInit(getProxyUrlFromExtraConfig(input.account.extraConfig), { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'User-Agent': GEMINI_CLI_USER_AGENT, - 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, - }, - }, true), - ); - if (!response.ok) { - const text = await response.text().catch(() => ''); - throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`); - } - const payload = await response.json() as { state?: unknown }; - if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') { - throw new Error(`Cloud AI API not enabled for project ${projectId}`); - } -} - -async function discoverAntigravityModelsFromCloud(input: { - site: typeof schema.sites.$inferSelect; - account: typeof schema.accounts.$inferSelect; -}): Promise { - const accessToken = (input.account.accessToken || '').trim(); - if (!accessToken) { - throw new Error('antigravity oauth access token missing'); - } - - const oauth = getOauthInfoFromAccount(input.account); - const requestBody = oauth?.projectId ? { project: oauth.projectId } : {}; - let lastError = ''; - - for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) { - const response = await fetch( - `${baseUrl}/v1internal:fetchAvailableModels`, - withSiteRecordProxyRequestInit(input.site, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT, - }, - body: JSON.stringify(requestBody), - }), - ); - if (!response.ok) { - lastError = await response.text().catch(() => '') || `HTTP ${response.status}`; - continue; - } - - const payload = await response.json(); - const models = normalizeModels(extractAntigravityModelIds(payload)); - if (models.length > 0) { - return models; - } - lastError = '未获取到可用模型'; - } - - throw new Error(lastError || '未获取到可用模型'); -} - function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; diff --git a/src/server/services/platformDiscoveryRegistry.ts b/src/server/services/platformDiscoveryRegistry.ts new file mode 100644 index 0000000..2dd2c07 --- /dev/null +++ b/src/server/services/platformDiscoveryRegistry.ts @@ -0,0 +1,252 @@ +import { fetch } from 'undici'; +import { schema } from '../db/index.js'; +import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js'; +import { withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js'; +import { getOauthInfoFromAccount } from './oauth/oauthAccount.js'; +import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js'; +import { + ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_MODELS_USER_AGENT, + ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_UPSTREAM_BASE_URL, +} from './oauth/antigravityProvider.js'; +import { + GEMINI_CLI_GOOGLE_API_CLIENT, + GEMINI_CLI_REQUIRED_SERVICE, + GEMINI_CLI_USER_AGENT, +} from './oauth/geminiCliProvider.js'; + +type PlatformDiscoverySite = typeof schema.sites.$inferSelect; +type PlatformDiscoveryAccount = typeof schema.accounts.$inferSelect; + +function normalizeDiscoveredModels(models: string[]): string[] { + const normalizedModels: string[] = []; + const seen = new Set(); + + for (const rawModel of models) { + if (typeof rawModel !== 'string') continue; + const modelName = rawModel.trim(); + if (!modelName) continue; + + const dedupeKey = modelName.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + normalizedModels.push(modelName); + } + + return normalizedModels; +} + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function buildCodexModelsEndpoint(baseUrl: string): string { + const normalized = normalizeBaseUrl(baseUrl); + return `${normalized}/models?client_version=${encodeURIComponent('1.0.0')}`; +} + +function extractCodexModelIds(payload: unknown): string[] { + const collection = (() => { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== 'object') return []; + const record = payload as Record; + if (Array.isArray(record.models)) return record.models; + if (Array.isArray(record.data)) return record.data; + if (Array.isArray(record.items)) return record.items; + return []; + })(); + + return collection.flatMap((item) => { + if (typeof item === 'string') return [item]; + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const record = item as Record; + const id = typeof record.id === 'string' + ? record.id + : (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : '')); + return id ? [id] : []; + }); +} + +function extractClaudeModelIds(payload: unknown): string[] { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return []; + const record = payload as { data?: unknown }; + if (!Array.isArray(record.data)) return []; + + return record.data.flatMap((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const value = item as { id?: unknown; name?: unknown }; + const id = typeof value.id === 'string' + ? value.id.trim() + : (typeof value.name === 'string' ? value.name.trim() : ''); + return id ? [id] : []; + }); +} + +function extractAntigravityModelIds(payload: unknown): string[] { + if (!payload || typeof payload !== 'object') return []; + const record = payload as { models?: unknown }; + if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) { + return Object.keys(record.models).map((name) => name.trim()).filter(Boolean); + } + if (!Array.isArray(record.models)) return []; + return record.models.flatMap((item) => { + if (typeof item === 'string') { + const trimmed = item.trim(); + return trimmed ? [trimmed] : []; + } + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const value = item as { id?: unknown; name?: unknown }; + const id = typeof value.id === 'string' + ? value.id.trim() + : (typeof value.name === 'string' ? value.name.trim() : ''); + return id ? [id] : []; + }); +} + +function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] { + const seen = new Set(); + return [ + siteUrl, + ANTIGRAVITY_UPSTREAM_BASE_URL, + ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, + ].flatMap((candidate) => { + const normalized = normalizeBaseUrl(candidate); + if (!normalized || seen.has(normalized)) return []; + seen.add(normalized); + return [normalized]; + }); +} + +export async function discoverCodexModelsFromCloud(input: { + site: PlatformDiscoverySite; + account: PlatformDiscoveryAccount; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('codex oauth access token missing'); + } + const oauth = getOauthInfoFromAccount(input.account); + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + Originator: 'codex_cli_rs', + }; + if (oauth?.accountId) { + headers['Chatgpt-Account-Id'] = oauth.accountId; + } + + const response = await fetch( + buildCodexModelsEndpoint(input.site.url), + withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`); + } + return normalizeDiscoveredModels(extractCodexModelIds(await response.json())); +} + +export async function discoverClaudeModelsFromCloud(input: { + site: PlatformDiscoverySite; + account: PlatformDiscoveryAccount; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('claude oauth access token missing'); + } + const response = await fetch( + `${input.site.url.replace(/\/+$/, '')}/v1/models`, + withSiteRecordProxyRequestInit(input.site, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, + }, + }), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`); + } + return normalizeDiscoveredModels(extractClaudeModelIds(await response.json())); +} + +export async function validateGeminiCliOauthConnection(input: { + account: PlatformDiscoveryAccount; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('gemini cli oauth access token missing'); + } + const oauth = getOauthInfoFromAccount(input.account); + const projectId = (oauth?.projectId || '').trim(); + if (!projectId) { + throw new Error('gemini cli oauth project id missing'); + } + const response = await fetch( + `https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, + withExplicitProxyRequestInit(getProxyUrlFromExtraConfig(input.account.extraConfig), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + }, true), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`); + } + const payload = await response.json() as { state?: unknown }; + if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') { + throw new Error(`Cloud AI API not enabled for project ${projectId}`); + } +} + +export async function discoverAntigravityModelsFromCloud(input: { + site: PlatformDiscoverySite; + account: PlatformDiscoveryAccount; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('antigravity oauth access token missing'); + } + + const oauth = getOauthInfoFromAccount(input.account); + const requestBody = oauth?.projectId ? { project: oauth.projectId } : {}; + let lastError = ''; + + for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) { + const response = await fetch( + `${baseUrl}/v1internal:fetchAvailableModels`, + withSiteRecordProxyRequestInit(input.site, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT, + }, + body: JSON.stringify(requestBody), + }), + ); + if (!response.ok) { + lastError = await response.text().catch(() => '') || `HTTP ${response.status}`; + continue; + } + + const payload = await response.json(); + const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload)); + if (models.length > 0) { + return models; + } + lastError = '未获取到可用模型'; + } + + throw new Error(lastError || '未获取到可用模型'); +} From 8b3653c7c91b7f197732be7dd5061e6684e94d6a Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 12:23:04 +0800 Subject: [PATCH 10/24] refactor: share schema metadata normalization rules --- src/server/db/schemaContract.ts | 77 ++-------- src/server/db/schemaIntrospection.ts | 121 ++------------- .../db/schemaMetadata.architecture.test.ts | 22 +++ src/server/db/schemaMetadata.ts | 139 ++++++++++++++++++ 4 files changed, 183 insertions(+), 176 deletions(-) create mode 100644 src/server/db/schemaMetadata.architecture.test.ts create mode 100644 src/server/db/schemaMetadata.ts diff --git a/src/server/db/schemaContract.ts b/src/server/db/schemaContract.ts index aee65d6..b241e84 100644 --- a/src/server/db/schemaContract.ts +++ b/src/server/db/schemaContract.ts @@ -3,14 +3,11 @@ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; - -export type LogicalColumnType = - | 'integer' - | 'real' - | 'text' - | 'boolean' - | 'datetime' - | 'json'; +import { + normalizeLogicalColumnType, + normalizeSchemaMetadataDefaultValue, + type LogicalColumnType, +} from './schemaMetadata.js'; export interface SchemaContractColumn { logicalType: LogicalColumnType; @@ -118,66 +115,16 @@ function applySqliteMigrations(sqlite: Database.Database, migrationsFolder: stri } function normalizeDefaultValue(defaultValue: string | null): string | null { - if (defaultValue == null) return null; - return String(defaultValue).trim() || null; -} - -function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean { - const normalizedColumn = columnName.toLowerCase(); - const normalizedDefault = (defaultValue || '').trim().toLowerCase(); - if (normalizedDefault === 'true' || normalizedDefault === 'false') { - return true; - } - return normalizedColumn.startsWith('is_') - || normalizedColumn.startsWith('use_') - || normalizedColumn.startsWith('has_') - || normalizedColumn.endsWith('_enabled') - || normalizedColumn.endsWith('_available') - || normalizedColumn === 'read' - || normalizedColumn === 'enabled' - || normalizedColumn === 'available' - || normalizedColumn === 'manual_override'; -} - -function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean { - const normalizedColumn = columnName.toLowerCase(); - const normalizedDefault = (defaultValue || '').toLowerCase(); - return normalizedColumn.endsWith('_at') - || normalizedColumn.endsWith('_until') - || normalizedColumn.endsWith('_refresh') - || normalizedDefault.includes('datetime(') - || normalizedDefault.includes('current_timestamp'); -} - -function isJsonLikeColumn(columnName: string): boolean { - const normalizedColumn = columnName.toLowerCase(); - return normalizedColumn.endsWith('_json') - || normalizedColumn.includes('snapshot') - || normalizedColumn.includes('mapping') - || normalizedColumn.includes('headers') - || normalizedColumn.includes('config') - || normalizedColumn.includes('details') - || normalizedColumn.includes('meta') - || normalizedColumn.includes('models') - || normalizedColumn.includes('route_ids') - || normalizedColumn.includes('multipliers'); + return normalizeSchemaMetadataDefaultValue(defaultValue); } function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType { - const normalizedType = declaredType.trim().toLowerCase(); - if (normalizedType.includes('int')) { - return isBooleanLikeColumn(columnName, defaultValue) ? 'boolean' : 'integer'; - } - if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float')) { - return 'real'; - } - if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) { - if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime'; - if (isJsonLikeColumn(columnName)) return 'json'; - return 'text'; - } - if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime'; - return 'text'; + return normalizeLogicalColumnType({ + columnName, + declaredType, + defaultValue, + dialect: 'sqlite', + }); } function readTables(sqlite: Database.Database): Record { diff --git a/src/server/db/schemaIntrospection.ts b/src/server/db/schemaIntrospection.ts index 0407e27..2c02c55 100644 --- a/src/server/db/schemaIntrospection.ts +++ b/src/server/db/schemaIntrospection.ts @@ -11,7 +11,6 @@ import { resolveGeneratedArtifactPath, } from './schemaArtifactGenerator.js'; import type { - LogicalColumnType, SchemaContract, SchemaContractColumn, SchemaContractForeignKey, @@ -20,6 +19,10 @@ import type { SchemaContractUnique, } from './schemaContract.js'; import { resolveMigrationsFolder } from './schemaContract.js'; +import { + normalizeLogicalColumnType, + type LogicalColumnType, +} from './schemaMetadata.js'; export type SchemaIntrospectionDialect = 'sqlite' | 'mysql' | 'postgres'; @@ -182,122 +185,18 @@ function normalizeDeleteRule(value: string | null | undefined): string | null { return value.trim().replace(/\s+/g, ' ').toUpperCase(); } -function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean { - const normalizedColumn = columnName.toLowerCase(); - const normalizedDefault = (defaultValue || '').trim().toLowerCase(); - if (normalizedDefault === 'true' || normalizedDefault === 'false') { - return true; - } - return normalizedColumn.startsWith('is_') - || normalizedColumn.startsWith('use_') - || normalizedColumn.startsWith('has_') - || normalizedColumn.endsWith('_enabled') - || normalizedColumn.endsWith('_available') - || normalizedColumn === 'read' - || normalizedColumn === 'enabled' - || normalizedColumn === 'available' - || normalizedColumn === 'manual_override'; -} - -function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean { - const normalizedColumn = columnName.toLowerCase(); - const normalizedDefault = (defaultValue || '').toLowerCase(); - return normalizedColumn.endsWith('_at') - || normalizedColumn.endsWith('_until') - || normalizedColumn.endsWith('_refresh') - || normalizedDefault.includes("datetime('now')") - || normalizedDefault.includes('current_timestamp') - || normalizedDefault.includes('now()'); -} - -function isJsonLikeColumn(columnName: string): boolean { - const normalizedColumn = columnName.toLowerCase(); - return normalizedColumn.endsWith('_json') - || normalizedColumn.includes('snapshot') - || normalizedColumn.includes('mapping') - || normalizedColumn.includes('headers') - || normalizedColumn.includes('config') - || normalizedColumn.includes('details') - || normalizedColumn.includes('meta') - || normalizedColumn.includes('models') - || normalizedColumn.includes('route_ids') - || normalizedColumn.includes('multipliers'); -} - export function normalizeSqlType( dialect: SchemaIntrospectionDialect, declaredType: string, columnName: string, rawDefaultValue: string | null = null, ): LogicalColumnType { - const normalizedType = declaredType.trim().toLowerCase(); - const normalizedDefault = normalizeDefaultValue(rawDefaultValue); - - if (dialect === 'mysql') { - if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) { - return 'boolean'; - } - if (normalizedType.includes('json')) { - return 'json'; - } - if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') { - return 'datetime'; - } - if (normalizedType.includes('int')) { - return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer'; - } - if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) { - return 'real'; - } - if (normalizedType.includes('char') || normalizedType.includes('text')) { - if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime'; - if (isJsonLikeColumn(columnName)) return 'json'; - return 'text'; - } - } - - if (dialect === 'postgres') { - if (normalizedType.includes('bool')) { - return 'boolean'; - } - if (normalizedType.includes('json')) { - return 'json'; - } - if (normalizedType.includes('timestamp') || normalizedType === 'date') { - return 'datetime'; - } - if (normalizedType.includes('int')) { - return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer'; - } - if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) { - return 'real'; - } - if (normalizedType.includes('char') || normalizedType.includes('text')) { - if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime'; - if (isJsonLikeColumn(columnName)) return 'json'; - return 'text'; - } - } - - if (normalizedType.includes('int')) { - return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer'; - } - if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) { - return 'real'; - } - if (normalizedType.includes('json')) { - return 'json'; - } - if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') { - return 'datetime'; - } - if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) { - if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime'; - if (isJsonLikeColumn(columnName)) return 'json'; - return 'text'; - } - if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime'; - return 'text'; + return normalizeLogicalColumnType({ + dialect, + declaredType, + columnName, + defaultValue: normalizeDefaultValue(rawDefaultValue), + }); } function normalizeDefaultValueForColumn( diff --git a/src/server/db/schemaMetadata.architecture.test.ts b/src/server/db/schemaMetadata.architecture.test.ts new file mode 100644 index 0000000..6a76538 --- /dev/null +++ b/src/server/db/schemaMetadata.architecture.test.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('schema metadata architecture boundaries', () => { + it('keeps logical column type heuristics in a shared schemaMetadata helper', () => { + const contractSource = readSource('./schemaContract.ts'); + const introspectionSource = readSource('./schemaIntrospection.ts'); + + expect(contractSource).toContain("from './schemaMetadata.js'"); + expect(introspectionSource).toContain("from './schemaMetadata.js'"); + + for (const source of [contractSource, introspectionSource]) { + expect(source).not.toContain('function isBooleanLikeColumn'); + expect(source).not.toContain('function isDateTimeLikeColumn'); + expect(source).not.toContain('function isJsonLikeColumn'); + } + }); +}); diff --git a/src/server/db/schemaMetadata.ts b/src/server/db/schemaMetadata.ts new file mode 100644 index 0000000..c630ea8 --- /dev/null +++ b/src/server/db/schemaMetadata.ts @@ -0,0 +1,139 @@ +export type LogicalColumnType = + | 'integer' + | 'real' + | 'text' + | 'boolean' + | 'datetime' + | 'json'; + +export type SchemaMetadataDialect = 'sqlite' | 'mysql' | 'postgres'; + +function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean { + const normalizedColumn = columnName.toLowerCase(); + const normalizedDefault = (defaultValue || '').trim().toLowerCase(); + if (normalizedDefault === 'true' || normalizedDefault === 'false') { + return true; + } + return normalizedColumn.startsWith('is_') + || normalizedColumn.startsWith('use_') + || normalizedColumn.startsWith('has_') + || normalizedColumn.endsWith('_enabled') + || normalizedColumn.endsWith('_available') + || normalizedColumn === 'read' + || normalizedColumn === 'enabled' + || normalizedColumn === 'available' + || normalizedColumn === 'manual_override'; +} + +function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean { + const normalizedColumn = columnName.toLowerCase(); + const normalizedDefault = (defaultValue || '').toLowerCase(); + return normalizedColumn.endsWith('_at') + || normalizedColumn.endsWith('_until') + || normalizedColumn.endsWith('_refresh') + || normalizedDefault.includes('datetime(') + || normalizedDefault.includes('current_timestamp') + || normalizedDefault.includes('now()'); +} + +function isJsonLikeColumn(columnName: string): boolean { + const normalizedColumn = columnName.toLowerCase(); + return normalizedColumn.endsWith('_json') + || normalizedColumn.includes('snapshot') + || normalizedColumn.includes('mapping') + || normalizedColumn.includes('headers') + || normalizedColumn.includes('config') + || normalizedColumn.includes('details') + || normalizedColumn.includes('meta') + || normalizedColumn.includes('models') + || normalizedColumn.includes('route_ids') + || normalizedColumn.includes('multipliers'); +} + +export function normalizeSchemaMetadataDefaultValue(defaultValue: string | null | undefined): string | null { + if (defaultValue == null) return null; + return String(defaultValue).trim() || null; +} + +export function normalizeLogicalColumnType(input: { + declaredType: string; + columnName: string; + defaultValue?: string | null; + dialect?: SchemaMetadataDialect; +}): LogicalColumnType { + const normalizedType = input.declaredType.trim().toLowerCase(); + const normalizedDefault = normalizeSchemaMetadataDefaultValue(input.defaultValue); + const dialect = input.dialect; + + if (dialect === 'mysql') { + if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) { + return 'boolean'; + } + if (normalizedType.includes('json')) { + return 'json'; + } + if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') { + return 'datetime'; + } + if (normalizedType.includes('int')) { + return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer'; + } + if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) { + return 'real'; + } + if (normalizedType.includes('char') || normalizedType.includes('text')) { + if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime'; + if (isJsonLikeColumn(input.columnName)) return 'json'; + return 'text'; + } + } + + if (dialect === 'postgres') { + if (normalizedType.includes('bool')) { + return 'boolean'; + } + if (normalizedType.includes('json')) { + return 'json'; + } + if (normalizedType.includes('timestamp') || normalizedType === 'date') { + return 'datetime'; + } + if (normalizedType.includes('int')) { + return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer'; + } + if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) { + return 'real'; + } + if (normalizedType.includes('char') || normalizedType.includes('text')) { + if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime'; + if (isJsonLikeColumn(input.columnName)) return 'json'; + return 'text'; + } + } + + if (normalizedType.includes('int')) { + return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer'; + } + if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) { + return 'real'; + } + if (normalizedType.includes('json')) { + return 'json'; + } + if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') { + return 'datetime'; + } + if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) { + if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime'; + if (isJsonLikeColumn(input.columnName)) return 'json'; + return 'text'; + } + if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime'; + return 'text'; +} + +export const __schemaMetadataTestUtils = { + isBooleanLikeColumn, + isDateTimeLikeColumn, + isJsonLikeColumn, +}; From 55aea6afc4f7ed238b85bc8dc5958bf60d2bc59d Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 12:44:38 +0800 Subject: [PATCH 11/24] refactor: separate brand registry from BrandIcon --- .../components/BrandIcon.architecture.test.ts | 16 + src/web/components/BrandIcon.tsx | 405 +----------------- src/web/components/brandRegistry.ts | 388 +++++++++++++++++ 3 files changed, 420 insertions(+), 389 deletions(-) create mode 100644 src/web/components/BrandIcon.architecture.test.ts create mode 100644 src/web/components/brandRegistry.ts diff --git a/src/web/components/BrandIcon.architecture.test.ts b/src/web/components/BrandIcon.architecture.test.ts new file mode 100644 index 0000000..2aae790 --- /dev/null +++ b/src/web/components/BrandIcon.architecture.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('BrandIcon architecture boundaries', () => { + it('keeps brand registry rules outside the React render component', () => { + const source = readSource('./BrandIcon.tsx'); + + expect(source).toContain("from './brandRegistry.js'"); + expect(source).not.toContain('const BRAND_DEFINITIONS'); + expect(source).not.toContain('function getBrand('); + }); +}); diff --git a/src/web/components/BrandIcon.tsx b/src/web/components/BrandIcon.tsx index 73f0002..22aee12 100644 --- a/src/web/components/BrandIcon.tsx +++ b/src/web/components/BrandIcon.tsx @@ -1,32 +1,25 @@ import React, { useEffect, useState, type CSSProperties } from 'react'; +import { + avatarLetters, + getBrand, + getBrandIconUrl, + hashColor, + normalizeBrandIconKey, + type BrandInfo, +} from './brandRegistry.js'; + +export type { BrandInfo } from './brandRegistry.js'; +export { + getBrand, + getBrandIconUrl, + hashColor, + normalizeBrandIconKey, +} from './brandRegistry.js'; const BRAND_ICON_VERSION = '1.83.0'; const ICON_CDN = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/dark`; const ICON_CDN_LIGHT = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/light`; -const LEGACY_ICON_ALIASES: Record = { - 'anthropic': 'claude-color', - 'claude.color': 'claude-color', - 'cohere.color': 'cohere-color', - 'doubao.color': 'doubao-color', - 'gemini.color': 'gemini-color', - 'hunyuan.color': 'hunyuan-color', - 'meta': 'meta-color', - 'meta-brand-color': 'meta-color', - 'minimax.color': 'minimax-color', - 'qwen.color': 'qwen-color', - 'spark.color': 'spark-color', - 'stability': 'stability-color', - 'stability-brand-color': 'stability-color', - 'stepfun': 'stepfun-color', - 'wenxin.color': 'wenxin-color', - 'xai': 'xai', - 'yi.color': 'yi-color', - 'zhipu.color': 'zhipu-color', - 'azure': 'microsoft-color', - 'bytedance-brand-color': 'bytedance-color', -}; - export function useIconCdn() { const [isDark, setIsDark] = useState(() => { if (typeof document === 'undefined') return false; @@ -43,372 +36,6 @@ export function useIconCdn() { return isDark ? ICON_CDN : ICON_CDN_LIGHT; } -export interface BrandMatchContext { - raw: string; - cleaned: string; - segments: string[]; -} - -interface InternalBrandMatchContext extends BrandMatchContext { - candidates: string[]; -} - -export interface BrandInfo { - name: string; - icon: string; - color: string; -} - -type BrandDefinition = BrandInfo & { - match: (context: InternalBrandMatchContext) => boolean; -}; - -function normalizeInput(value: string): string { - return String(value || '').trim().toLowerCase(); -} - -function stripCommonWrappers(value: string): string { - return value - .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') - .replace(/^re:\s*/g, '') - .replace(/^\^+/, '') - .replace(/\$+$/, '') - .trim(); -} - -function collectBrandCandidates(modelName: string): string[] { - const queue: string[] = []; - const seen = new Set(); - const push = (value: string) => { - const normalized = normalizeInput(value); - if (!normalized || seen.has(normalized)) return; - seen.add(normalized); - queue.push(normalized); - }; - - push(modelName); - - for (let index = 0; index < queue.length; index += 1) { - const candidate = queue[index]!; - const cleaned = stripCommonWrappers(candidate); - push(cleaned); - - if (cleaned.includes('/')) { - for (const part of cleaned.split('/')) push(part); - } - if (cleaned.includes(':')) { - for (const part of cleaned.split(':')) push(part); - } - if (cleaned.includes(',')) { - for (const part of cleaned.split(',')) push(part); - } - } - - return queue; -} - -function buildMatchContext(modelName: string): InternalBrandMatchContext { - const candidates = collectBrandCandidates(modelName); - const raw = candidates[0] || normalizeInput(modelName); - const cleaned = stripCommonWrappers(raw); - const segments = Array.from(new Set( - candidates - .flatMap((candidate) => candidate.split(/[/:,\s]+/g)) - .map((segment) => segment.trim()) - .filter(Boolean), - )); - - return { - raw, - cleaned, - segments, - candidates, - }; -} - -function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => ( - context.raw.includes(needle) - || context.cleaned.includes(needle) - || context.candidates.some((candidate) => candidate.includes(needle)) - )); -} - -function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => ( - context.raw.startsWith(needle) - || context.cleaned.startsWith(needle) - || context.segments.some((segment) => segment.startsWith(needle)) - || context.candidates.some((candidate) => candidate.startsWith(needle)) - )); -} - -function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean { - return needles.some((needle) => context.segments.includes(needle)); -} - -function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean { - return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate)); -} - -const BRAND_DEFINITIONS: BrandDefinition[] = [ - { - name: 'OpenAI', - icon: 'openai', - color: 'linear-gradient(135deg, #10a37f, #1a7f5a)', - match: (context) => ( - startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini']) - || startsWithAny(context, ['o1', 'o3', 'o4', 'tts']) - ), - }, - { - name: 'Anthropic', - icon: 'claude-color', - color: 'linear-gradient(135deg, #d4a574, #c4956a)', - match: (context) => includesAny(context, ['claude']), - }, - { - name: 'Google', - icon: 'gemini-color', - color: 'linear-gradient(135deg, #4285f4, #34a853)', - match: (context) => ( - includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa']) - || startsWithAny(context, ['veo', 'google/']) - ), - }, - { - name: 'DeepSeek', - icon: 'deepseek-color', - color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)', - match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']), - }, - { - name: '通义千问', - icon: 'qwen-color', - color: 'linear-gradient(135deg, #615cf7, #9b8afb)', - match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']), - }, - { - name: '智谱 AI', - icon: 'zhipu-color', - color: 'linear-gradient(135deg, #3b6cf5, #6366f1)', - match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']), - }, - { - name: 'Meta', - icon: 'meta-color', - color: 'linear-gradient(135deg, #0668E1, #1877f2)', - match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']), - }, - { - name: 'Mistral', - icon: 'mistral-color', - color: 'linear-gradient(135deg, #f7d046, #f2a900)', - match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']), - }, - { - name: 'Moonshot', - icon: 'moonshot', - color: 'linear-gradient(135deg, #000000, #333333)', - match: (context) => includesAny(context, ['moonshot', 'kimi']), - }, - { - name: '零一万物', - icon: 'yi-color', - color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)', - match: (context) => ( - startsWithAny(context, ['yi-']) - || matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/) - ), - }, - { - name: '文心一言', - icon: 'wenxin-color', - color: 'linear-gradient(135deg, #2932e1, #4468f2)', - match: (context) => includesAny(context, ['ernie', 'eb-']), - }, - { - name: '讯飞星火', - icon: 'spark-color', - color: 'linear-gradient(135deg, #0070f3, #00d4ff)', - match: (context) => includesAny(context, ['spark', 'generalv']), - }, - { - name: '腾讯混元', - icon: 'hunyuan-color', - color: 'linear-gradient(135deg, #00b7ff, #0052d9)', - match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']), - }, - { - name: '豆包', - icon: 'doubao-color', - color: 'linear-gradient(135deg, #3b5bdb, #7048e8)', - match: (context) => includesAny(context, ['doubao']), - }, - { - name: 'MiniMax', - icon: 'minimax-color', - color: 'linear-gradient(135deg, #6366f1, #818cf8)', - match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']), - }, - { - name: 'Cohere', - icon: 'cohere-color', - color: 'linear-gradient(135deg, #39594d, #5ba77f)', - match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']), - }, - { - name: 'Microsoft', - icon: 'microsoft-color', - color: 'linear-gradient(135deg, #00bcf2, #0078d4)', - match: (context) => ( - includesAny(context, ['microsoft/', 'phi-', 'kosmos']) - || hasExactSegment(context, ['phi4']) - ), - }, - { - name: 'xAI', - icon: 'xai', - color: 'linear-gradient(135deg, #111, #444)', - match: (context) => includesAny(context, ['grok']), - }, - { - name: '阶跃星辰', - icon: 'stepfun-color', - color: 'linear-gradient(135deg, #0066ff, #3399ff)', - match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']), - }, - { - name: 'Stability', - icon: 'stability-color', - color: 'linear-gradient(135deg, #8b5cf6, #a855f7)', - match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']), - }, - { - name: 'NVIDIA', - icon: 'nvidia-color', - color: 'linear-gradient(135deg, #76b900, #4a8c0b)', - match: (context) => ( - includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos']) - || startsWithAny(context, ['nv-']) - ), - }, - { - name: 'IBM', - icon: 'ibm', - color: 'linear-gradient(135deg, #0f62fe, #4589ff)', - match: (context) => includesAny(context, ['ibm/', 'granite']), - }, - { - name: 'BAAI', - icon: 'baai', - color: 'linear-gradient(135deg, #111827, #374151)', - match: (context) => includesAny(context, ['baai/', 'bge-']), - }, - { - name: 'ByteDance', - icon: 'bytedance-color', - color: 'linear-gradient(135deg, #325ab4, #0f66ff)', - match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']), - }, - { - name: 'InternLM', - icon: 'internlm-color', - color: 'linear-gradient(135deg, #1b3882, #4063c5)', - match: (context) => includesAny(context, ['internlm']), - }, - { - name: 'Midjourney', - icon: 'midjourney', - color: 'linear-gradient(135deg, #4c6ef5, #748ffc)', - match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']), - }, - { - name: 'DeepL', - icon: 'deepl-color', - color: 'linear-gradient(135deg, #0f2b46, #21476f)', - match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']), - }, - { - name: 'Jina AI', - icon: 'jina', - color: 'linear-gradient(135deg, #111827, #4b5563)', - match: (context) => includesAny(context, ['jina']), - }, -]; - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({ - brand, - boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`), -})); - -export function normalizeBrandIconKey(icon: string | null | undefined): string | null { - const normalized = normalizeInput(icon).replace(/\./g, '-'); - if (!normalized) return null; - return LEGACY_ICON_ALIASES[normalized] || normalized; -} - -export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null { - const normalized = normalizeBrandIconKey(icon); - if (!normalized) return null; - return `${cdn}/${normalized}.png`; -} - -export function getBrand(modelName: string): BrandInfo | null { - const context = buildMatchContext(modelName); - - for (const definition of BRAND_DEFINITIONS) { - if (definition.match(context)) { - return { - name: definition.name, - icon: definition.icon, - color: definition.color, - }; - } - } - - for (const candidate of context.candidates) { - for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) { - if (rule.boundaryRegex.test(candidate)) { - return { - name: rule.brand.name, - icon: rule.brand.icon, - color: rule.brand.color, - }; - } - } - } - - return null; -} - -const FALLBACK_COLORS = [ - 'linear-gradient(135deg, #4f46e5, #818cf8)', - 'linear-gradient(135deg, #059669, #34d399)', - 'linear-gradient(135deg, #2563eb, #60a5fa)', - 'linear-gradient(135deg, #d946ef, #f0abfc)', - 'linear-gradient(135deg, #ea580c, #fb923c)', - 'linear-gradient(135deg, #0891b2, #22d3ee)', - 'linear-gradient(135deg, #7c3aed, #a78bfa)', - 'linear-gradient(135deg, #dc2626, #f87171)', -]; - -export function hashColor(name: string): string { - let h = 0; - for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0; - return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; -} - -function avatarLetters(name: string): string { - const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean); - if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase(); - return name.slice(0, 2).toUpperCase(); -} - type BrandGlyphProps = { brand?: Pick | null; model?: string | null; diff --git a/src/web/components/brandRegistry.ts b/src/web/components/brandRegistry.ts new file mode 100644 index 0000000..5be2e35 --- /dev/null +++ b/src/web/components/brandRegistry.ts @@ -0,0 +1,388 @@ +export interface BrandMatchContext { + raw: string; + cleaned: string; + segments: string[]; +} + +interface InternalBrandMatchContext extends BrandMatchContext { + candidates: string[]; +} + +export interface BrandInfo { + name: string; + icon: string; + color: string; +} + +type BrandDefinition = BrandInfo & { + match: (context: InternalBrandMatchContext) => boolean; +}; + +const LEGACY_ICON_ALIASES: Record = { + anthropic: 'claude-color', + 'claude.color': 'claude-color', + 'cohere.color': 'cohere-color', + 'doubao.color': 'doubao-color', + 'gemini.color': 'gemini-color', + 'hunyuan.color': 'hunyuan-color', + meta: 'meta-color', + 'meta-brand-color': 'meta-color', + 'minimax.color': 'minimax-color', + 'qwen.color': 'qwen-color', + 'spark.color': 'spark-color', + stability: 'stability-color', + 'stability-brand-color': 'stability-color', + stepfun: 'stepfun-color', + 'wenxin.color': 'wenxin-color', + xai: 'xai', + 'yi.color': 'yi-color', + 'zhipu.color': 'zhipu-color', + azure: 'microsoft-color', + 'bytedance-brand-color': 'bytedance-color', +}; + +function normalizeInput(value: string): string { + return String(value || '').trim().toLowerCase(); +} + +function stripCommonWrappers(value: string): string { + return value + .replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, '') + .replace(/^re:\s*/g, '') + .replace(/^\^+/, '') + .replace(/\$+$/, '') + .trim(); +} + +function collectBrandCandidates(modelName: string): string[] { + const queue: string[] = []; + const seen = new Set(); + const push = (value: string) => { + const normalized = normalizeInput(value); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + queue.push(normalized); + }; + + push(modelName); + + for (let index = 0; index < queue.length; index += 1) { + const candidate = queue[index]!; + const cleaned = stripCommonWrappers(candidate); + push(cleaned); + + if (cleaned.includes('/')) { + for (const part of cleaned.split('/')) push(part); + } + if (cleaned.includes(':')) { + for (const part of cleaned.split(':')) push(part); + } + if (cleaned.includes(',')) { + for (const part of cleaned.split(',')) push(part); + } + } + + return queue; +} + +function buildMatchContext(modelName: string): InternalBrandMatchContext { + const candidates = collectBrandCandidates(modelName); + const raw = candidates[0] || normalizeInput(modelName); + const cleaned = stripCommonWrappers(raw); + const segments = Array.from(new Set( + candidates + .flatMap((candidate) => candidate.split(/[/:,\s]+/g)) + .map((segment) => segment.trim()) + .filter(Boolean), + )); + + return { + raw, + cleaned, + segments, + candidates, + }; +} + +function includesAny(context: InternalBrandMatchContext, needles: string[]): boolean { + return needles.some((needle) => ( + context.raw.includes(needle) + || context.cleaned.includes(needle) + || context.candidates.some((candidate) => candidate.includes(needle)) + )); +} + +function startsWithAny(context: InternalBrandMatchContext, needles: string[]): boolean { + return needles.some((needle) => ( + context.raw.startsWith(needle) + || context.cleaned.startsWith(needle) + || context.segments.some((segment) => segment.startsWith(needle)) + || context.candidates.some((candidate) => candidate.startsWith(needle)) + )); +} + +function hasExactSegment(context: InternalBrandMatchContext, needles: string[]): boolean { + return needles.some((needle) => context.segments.includes(needle)); +} + +function matchesBoundary(context: InternalBrandMatchContext, pattern: RegExp): boolean { + return pattern.test(context.raw) || pattern.test(context.cleaned) || context.candidates.some((candidate) => pattern.test(candidate)); +} + +const BRAND_DEFINITIONS: BrandDefinition[] = [ + { + name: 'OpenAI', + icon: 'openai', + color: 'linear-gradient(135deg, #10a37f, #1a7f5a)', + match: (context) => ( + startsWithAny(context, ['gpt', 'chatgpt', 'dall-e', 'whisper', 'text-embedding', 'text-moderation', 'davinci', 'babbage', 'codex-mini']) + || startsWithAny(context, ['o1', 'o3', 'o4', 'tts']) + ), + }, + { + name: 'Anthropic', + icon: 'claude-color', + color: 'linear-gradient(135deg, #d4a574, #c4956a)', + match: (context) => includesAny(context, ['claude']), + }, + { + name: 'Google', + icon: 'gemini-color', + color: 'linear-gradient(135deg, #4285f4, #34a853)', + match: (context) => ( + includesAny(context, ['gemini', 'gemma', 'google/', 'palm', 'paligemma', 'shieldgemma', 'recurrentgemma', 'deplot', 'codegemma', 'imagen', 'learnlm', 'aqa']) + || startsWithAny(context, ['veo', 'google/']) + ), + }, + { + name: 'DeepSeek', + icon: 'deepseek-color', + color: 'linear-gradient(135deg, #4d6bfe, #44a3ec)', + match: (context) => includesAny(context, ['deepseek']) || hasExactSegment(context, ['ds-chat']), + }, + { + name: '通义千问', + icon: 'qwen-color', + color: 'linear-gradient(135deg, #615cf7, #9b8afb)', + match: (context) => includesAny(context, ['qwen', 'qwq', 'tongyi']), + }, + { + name: '智谱 AI', + icon: 'zhipu-color', + color: 'linear-gradient(135deg, #3b6cf5, #6366f1)', + match: (context) => includesAny(context, ['glm', 'chatglm', 'codegeex', 'cogview', 'cogvideo']), + }, + { + name: 'Meta', + icon: 'meta-color', + color: 'linear-gradient(135deg, #0668E1, #1877f2)', + match: (context) => includesAny(context, ['llama', 'code-llama', 'codellama']), + }, + { + name: 'Mistral', + icon: 'mistral-color', + color: 'linear-gradient(135deg, #f7d046, #f2a900)', + match: (context) => includesAny(context, ['mistral', 'mixtral', 'codestral', 'pixtral', 'ministral', 'voxtral', 'magistral']), + }, + { + name: 'Moonshot', + icon: 'moonshot', + color: 'linear-gradient(135deg, #000000, #333333)', + match: (context) => includesAny(context, ['moonshot', 'kimi']), + }, + { + name: '零一万物', + icon: 'yi-color', + color: 'linear-gradient(135deg, #1d4ed8, #3b82f6)', + match: (context) => ( + startsWithAny(context, ['yi-']) + || matchesBoundary(context, /(^|[/:_\-\s])yi(?=$|[/:_\-\s])/) + ), + }, + { + name: '文心一言', + icon: 'wenxin-color', + color: 'linear-gradient(135deg, #2932e1, #4468f2)', + match: (context) => includesAny(context, ['ernie', 'eb-']), + }, + { + name: '讯飞星火', + icon: 'spark-color', + color: 'linear-gradient(135deg, #0070f3, #00d4ff)', + match: (context) => includesAny(context, ['spark', 'generalv']), + }, + { + name: '腾讯混元', + icon: 'hunyuan-color', + color: 'linear-gradient(135deg, #00b7ff, #0052d9)', + match: (context) => includesAny(context, ['hunyuan', 'tencent-hunyuan']), + }, + { + name: '豆包', + icon: 'doubao-color', + color: 'linear-gradient(135deg, #3b5bdb, #7048e8)', + match: (context) => includesAny(context, ['doubao']), + }, + { + name: 'MiniMax', + icon: 'minimax-color', + color: 'linear-gradient(135deg, #6366f1, #818cf8)', + match: (context) => includesAny(context, ['minimax', 'abab']) || hasExactSegment(context, ['mini2.1']), + }, + { + name: 'Cohere', + icon: 'cohere-color', + color: 'linear-gradient(135deg, #39594d, #5ba77f)', + match: (context) => includesAny(context, ['command', 'c4ai-']) || startsWithAny(context, ['embed-']), + }, + { + name: 'Microsoft', + icon: 'microsoft-color', + color: 'linear-gradient(135deg, #00bcf2, #0078d4)', + match: (context) => ( + includesAny(context, ['microsoft/', 'phi-', 'kosmos']) + || hasExactSegment(context, ['phi4']) + ), + }, + { + name: 'xAI', + icon: 'xai', + color: 'linear-gradient(135deg, #111, #444)', + match: (context) => includesAny(context, ['grok']), + }, + { + name: '阶跃星辰', + icon: 'stepfun-color', + color: 'linear-gradient(135deg, #0066ff, #3399ff)', + match: (context) => includesAny(context, ['stepfun']) || startsWithAny(context, ['step-', 'step3']), + }, + { + name: 'Stability', + icon: 'stability-color', + color: 'linear-gradient(135deg, #8b5cf6, #a855f7)', + match: (context) => includesAny(context, ['flux', 'stablediffusion', 'stable-diffusion', 'sdxl']) || startsWithAny(context, ['sd3']), + }, + { + name: 'NVIDIA', + icon: 'nvidia-color', + color: 'linear-gradient(135deg, #76b900, #4a8c0b)', + match: (context) => ( + includesAny(context, ['nvidia/', 'nvclip', 'nemotron', 'nemoretriever', 'neva', 'riva-translate', 'cosmos']) + || startsWithAny(context, ['nv-']) + ), + }, + { + name: 'IBM', + icon: 'ibm', + color: 'linear-gradient(135deg, #0f62fe, #4589ff)', + match: (context) => includesAny(context, ['ibm/', 'granite']), + }, + { + name: 'BAAI', + icon: 'baai', + color: 'linear-gradient(135deg, #111827, #374151)', + match: (context) => includesAny(context, ['baai/', 'bge-']), + }, + { + name: 'ByteDance', + icon: 'bytedance-color', + color: 'linear-gradient(135deg, #325ab4, #0f66ff)', + match: (context) => includesAny(context, ['bytedance', 'seed-oss', 'kolors', 'kwai', 'kwaipilot']) || startsWithAny(context, ['wan-', 'kat-']), + }, + { + name: 'InternLM', + icon: 'internlm-color', + color: 'linear-gradient(135deg, #1b3882, #4063c5)', + match: (context) => includesAny(context, ['internlm']), + }, + { + name: 'Midjourney', + icon: 'midjourney', + color: 'linear-gradient(135deg, #4c6ef5, #748ffc)', + match: (context) => includesAny(context, ['midjourney']) || startsWithAny(context, ['mj_']), + }, + { + name: 'DeepL', + icon: 'deepl-color', + color: 'linear-gradient(135deg, #0f2b46, #21476f)', + match: (context) => startsWithAny(context, ['deepl-']) || includesAny(context, ['deepl/']), + }, + { + name: 'Jina AI', + icon: 'jina', + color: 'linear-gradient(135deg, #111827, #4b5563)', + match: (context) => includesAny(context, ['jina']), + }, +]; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const BRAND_FALLBACK_BOUNDARY_RULES = BRAND_DEFINITIONS.map((brand) => ({ + brand, + boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(brand.name.toLowerCase())}(?=$|[^a-z0-9])`), +})); + +const FALLBACK_COLORS = [ + 'linear-gradient(135deg, #4f46e5, #818cf8)', + 'linear-gradient(135deg, #059669, #34d399)', + 'linear-gradient(135deg, #2563eb, #60a5fa)', + 'linear-gradient(135deg, #d946ef, #f0abfc)', + 'linear-gradient(135deg, #ea580c, #fb923c)', + 'linear-gradient(135deg, #0891b2, #22d3ee)', + 'linear-gradient(135deg, #7c3aed, #a78bfa)', + 'linear-gradient(135deg, #dc2626, #f87171)', +]; + +export function normalizeBrandIconKey(icon: string | null | undefined): string | null { + const normalized = normalizeInput(icon || '').replace(/\./g, '-'); + if (!normalized) return null; + return LEGACY_ICON_ALIASES[normalized] || normalized; +} + +export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null { + const normalized = normalizeBrandIconKey(icon); + if (!normalized) return null; + return `${cdn}/${normalized}.png`; +} + +export function getBrand(modelName: string): BrandInfo | null { + const context = buildMatchContext(modelName); + + for (const definition of BRAND_DEFINITIONS) { + if (definition.match(context)) { + return { + name: definition.name, + icon: definition.icon, + color: definition.color, + }; + } + } + + for (const candidate of context.candidates) { + for (const rule of BRAND_FALLBACK_BOUNDARY_RULES) { + if (rule.boundaryRegex.test(candidate)) { + return { + name: rule.brand.name, + icon: rule.brand.icon, + color: rule.brand.color, + }; + } + } + } + + return null; +} + +export function hashColor(name: string): string { + let h = 0; + for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0; + return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; +} + +export function avatarLetters(name: string): string { + const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} From b82d7c31d91ae382c226a163174a04d0f0b48fec Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 13:04:09 +0800 Subject: [PATCH 12/24] refactor: derive legacy schema compat from feature specs --- .../db/accountTokenSchemaCompatibility.ts | 4 +- .../legacySchemaCompat.architecture.test.ts | 18 ++++ src/server/db/legacySchemaCompat.ts | 96 +++++++++++-------- src/server/db/proxyFileSchemaCompatibility.ts | 84 +++++++++++----- .../db/routeGroupingSchemaCompatibility.ts | 8 +- src/server/db/siteSchemaCompatibility.ts | 8 +- 6 files changed, 143 insertions(+), 75 deletions(-) create mode 100644 src/server/db/legacySchemaCompat.architecture.test.ts diff --git a/src/server/db/accountTokenSchemaCompatibility.ts b/src/server/db/accountTokenSchemaCompatibility.ts index c97b4a8..256ab2e 100644 --- a/src/server/db/accountTokenSchemaCompatibility.ts +++ b/src/server/db/accountTokenSchemaCompatibility.ts @@ -7,13 +7,13 @@ export interface AccountTokenSchemaInspector { execute(sqlText: string): Promise; } -type AccountTokenColumnCompatibilitySpec = { +export type AccountTokenColumnCompatibilitySpec = { table: 'account_tokens'; column: string; addSql: Record; }; -const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [ +export const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [ { table: 'account_tokens', column: 'token_group', diff --git a/src/server/db/legacySchemaCompat.architecture.test.ts b/src/server/db/legacySchemaCompat.architecture.test.ts new file mode 100644 index 0000000..568a4fe --- /dev/null +++ b/src/server/db/legacySchemaCompat.architecture.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +describe('legacySchemaCompat architecture boundaries', () => { + it('derives feature-owned legacy mutations from compatibility specs instead of hardcoding a second full whitelist', () => { + const source = readSource('./legacySchemaCompat.ts'); + + expect(source).toContain('ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS'); + expect(source).toContain('PROXY_FILE_COLUMN_COMPATIBILITY_SPECS'); + expect(source).toContain('ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS'); + expect(source).toContain('SITE_COLUMN_COMPATIBILITY_SPECS'); + expect(source).toContain('SITE_TABLE_COMPATIBILITY_SPECS'); + }); +}); diff --git a/src/server/db/legacySchemaCompat.ts b/src/server/db/legacySchemaCompat.ts index 6c187d9..57b6845 100644 --- a/src/server/db/legacySchemaCompat.ts +++ b/src/server/db/legacySchemaCompat.ts @@ -1,13 +1,19 @@ import { + ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS, ensureAccountTokenSchemaCompatibility, type AccountTokenSchemaInspector, } from './accountTokenSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility, + PROXY_FILE_COLUMN_COMPATIBILITY_SPECS, + PROXY_FILE_INDEX_COMPATIBILITY_SPECS, + PROXY_FILE_TABLE_COMPATIBILITY_SPECS, type ProxyFileSchemaInspector, } from './proxyFileSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility, + ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS, + ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS, type RouteGroupingSchemaInspector, } from './routeGroupingSchemaCompatibility.js'; import { @@ -17,6 +23,8 @@ import { } from './sharedIndexSchemaCompatibility.js'; import { ensureSiteSchemaCompatibility, + SITE_COLUMN_COMPATIBILITY_SPECS, + SITE_TABLE_COMPATIBILITY_SPECS, type SiteSchemaInspector, } from './siteSchemaCompatibility.js'; @@ -29,36 +37,16 @@ export interface LegacySchemaCompatInspector extends AccountTokenSchemaInspector, SharedIndexSchemaInspector {} -const LEGACY_COMPAT_TABLES = new Set([ +const BOOTSTRAP_OWNED_LEGACY_TABLES = [ 'account_tokens', 'token_model_availability', - 'route_group_sources', 'proxy_video_tasks', - 'proxy_files', 'downstream_api_keys', - 'site_disabled_models', -]); +]; -const LEGACY_COMPAT_COLUMNS = new Set([ +const BOOTSTRAP_OWNED_LEGACY_COLUMNS = [ 'sites.status', - 'sites.proxy_url', - 'sites.use_system_proxy', - 'sites.custom_headers', - 'sites.external_checkin_url', - 'sites.global_weight', - 'account_tokens.token_group', - 'account_tokens.value_status', - 'token_routes.display_name', - 'token_routes.display_icon', - 'token_routes.route_mode', - 'token_routes.decision_snapshot', - 'token_routes.decision_refreshed_at', - 'token_routes.routing_strategy', 'route_channels.token_id', - 'route_channels.source_model', - 'route_channels.last_selected_at', - 'route_channels.consecutive_fail_count', - 'route_channels.cooldown_level', 'proxy_video_tasks.status_snapshot', 'proxy_video_tasks.upstream_response_meta', 'proxy_video_tasks.last_upstream_status', @@ -71,16 +59,12 @@ const LEGACY_COMPAT_COLUMNS = new Set([ 'proxy_logs.client_app_name', 'proxy_logs.client_confidence', 'proxy_logs.downstream_api_key_id', -]); +]; -const LEGACY_COMPAT_INDEXES = new Set([ - 'site_disabled_models_site_model_unique', - 'site_disabled_models_site_id_idx', +const BOOTSTRAP_OWNED_LEGACY_INDEXES = [ 'token_model_availability_token_model_unique', 'proxy_video_tasks_public_id_unique', 'proxy_video_tasks_upstream_video_id_idx', - 'proxy_files_public_id_unique', - 'proxy_files_owner_lookup_idx', 'downstream_api_keys_key_unique', 'downstream_api_keys_name_idx', 'downstream_api_keys_enabled_idx', @@ -88,23 +72,53 @@ const LEGACY_COMPAT_INDEXES = new Set([ 'proxy_logs_client_app_id_created_at_idx', 'proxy_logs_client_family_created_at_idx', 'proxy_logs_downstream_api_key_created_at_idx', - 'route_group_sources_group_source_unique', - 'route_group_sources_source_route_id_idx', - ...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName), -]); +]; function normalizeSqlText(sqlText: string): string { return sqlText.trim().replace(/\s+/g, ' ').toLowerCase(); } -const LEGACY_COMPAT_UPDATES = new Set([ - 'UPDATE sites SET use_system_proxy = 0 WHERE use_system_proxy IS NULL;', - 'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL', - 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL', - 'UPDATE sites SET global_weight = 1 WHERE global_weight IS NULL OR global_weight <= 0;', - 'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0', - 'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0', -].map((sqlText) => normalizeSqlText(sqlText))); +function extractIndexName(sqlText: string): string | null { + const match = normalizeSqlText(sqlText).match( + /^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i, + ); + return match?.[1] ?? null; +} + +const LEGACY_COMPAT_TABLES = new Set([ + ...SITE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table), + ...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table), + ...PROXY_FILE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table), + ...BOOTSTRAP_OWNED_LEGACY_TABLES, +]); + +const LEGACY_COMPAT_COLUMNS = new Set([ + ...SITE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `sites.${spec.column}`), + ...ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`), + ...ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`), + ...PROXY_FILE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`), + ...BOOTSTRAP_OWNED_LEGACY_COLUMNS, +]); + +const LEGACY_COMPAT_INDEXES = new Set([ + ...SITE_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => spec.postCreateSql ? Object.values(spec.postCreateSql) : []) + .flat() + .map((sqlText) => extractIndexName(sqlText)) + .filter((indexName): indexName is string => Boolean(indexName)), + ...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => Object.values(spec.createSql)) + .flat() + .map((sqlText) => extractIndexName(sqlText)) + .filter((indexName): indexName is string => Boolean(indexName)), + ...PROXY_FILE_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName), + ...BOOTSTRAP_OWNED_LEGACY_INDEXES, + ...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName), +]); + +const LEGACY_COMPAT_UPDATES = new Set( + SITE_COLUMN_COMPATIBILITY_SPECS + .flatMap((spec) => spec.normalizeSql ? Object.values(spec.normalizeSql) : []) + .map((sqlText) => normalizeSqlText(sqlText)), +); export function classifyLegacyCompatMutation(sqlText: string): LegacySchemaCompatClassification { const normalized = normalizeSqlText(sqlText); diff --git a/src/server/db/proxyFileSchemaCompatibility.ts b/src/server/db/proxyFileSchemaCompatibility.ts index 98f5f00..9a8e6de 100644 --- a/src/server/db/proxyFileSchemaCompatibility.ts +++ b/src/server/db/proxyFileSchemaCompatibility.ts @@ -8,18 +8,30 @@ export interface ProxyFileSchemaInspector { } type ProxyFileColumnCompatibilitySpec = { + table: 'proxy_files'; column: string; addSql: Record; }; -const CREATE_TABLE_SQL: Record = { - sqlite: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)', - postgres: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" SERIAL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)', - mysql: 'CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, `public_id` VARCHAR(191) NOT NULL, `owner_type` VARCHAR(64) NOT NULL, `owner_id` VARCHAR(191) NOT NULL, `filename` TEXT NOT NULL, `mime_type` VARCHAR(191) NOT NULL, `purpose` TEXT NULL, `byte_size` INTEGER NOT NULL, `sha256` VARCHAR(191) NOT NULL, `content_base64` LONGTEXT NOT NULL, `created_at` TEXT NULL, `updated_at` TEXT NULL, `deleted_at` TEXT NULL)', +export type ProxyFileTableCompatibilitySpec = { + table: 'proxy_files'; + createSql: Record; }; -const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ +export const PROXY_FILE_TABLE_COMPATIBILITY_SPECS: ProxyFileTableCompatibilitySpec[] = [ { + table: 'proxy_files', + createSql: { + sqlite: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)', + postgres: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" SERIAL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)', + mysql: 'CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, `public_id` VARCHAR(191) NOT NULL, `owner_type` VARCHAR(64) NOT NULL, `owner_id` VARCHAR(191) NOT NULL, `filename` TEXT NOT NULL, `mime_type` VARCHAR(191) NOT NULL, `purpose` TEXT NULL, `byte_size` INTEGER NOT NULL, `sha256` VARCHAR(191) NOT NULL, `content_base64` LONGTEXT NOT NULL, `created_at` TEXT NULL, `updated_at` TEXT NULL, `deleted_at` TEXT NULL)', + }, + }, +]; + +export const PROXY_FILE_COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ + { + table: 'proxy_files', column: 'public_id', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT', @@ -28,6 +40,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'owner_type', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT', @@ -36,6 +49,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'owner_id', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT', @@ -44,6 +58,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'filename', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "filename" TEXT', @@ -52,6 +67,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'mime_type', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT', @@ -60,6 +76,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'purpose', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "purpose" TEXT', @@ -68,6 +85,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'byte_size', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "byte_size" INTEGER', @@ -76,6 +94,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'sha256', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT', @@ -84,6 +103,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'content_base64', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "content_base64" TEXT', @@ -92,6 +112,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'created_at', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "created_at" TEXT', @@ -100,6 +121,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'updated_at', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "updated_at" TEXT', @@ -108,6 +130,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, }, { + table: 'proxy_files', column: 'deleted_at', addSql: { sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "deleted_at" TEXT', @@ -117,21 +140,33 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [ }, ]; -const CREATE_INDEX_SQL: Record = { - sqlite: [ - 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")', - 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', - ], - postgres: [ - 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")', - 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', - ], - mysql: [ - 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', - 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))', - ], +export type ProxyFileIndexCompatibilitySpec = { + table: 'proxy_files'; + indexName: string; + createSql: Record; }; +export const PROXY_FILE_INDEX_COMPATIBILITY_SPECS: ProxyFileIndexCompatibilitySpec[] = [ + { + table: 'proxy_files', + indexName: 'proxy_files_public_id_unique', + createSql: { + sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")', + postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")', + mysql: 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', + }, + }, + { + table: 'proxy_files', + indexName: 'proxy_files_owner_lookup_idx', + createSql: { + sqlite: 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', + postgres: 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")', + mysql: 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))', + }, + }, +]; + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -155,16 +190,17 @@ async function executeIgnoreDuplicate(inspector: ProxyFileSchemaInspector, sqlTe } export async function ensureProxyFileSchemaCompatibility(inspector: ProxyFileSchemaInspector): Promise { - if (!await inspector.tableExists('proxy_files')) { - await executeIgnoreDuplicate(inspector, CREATE_TABLE_SQL[inspector.dialect]); + const [tableSpec] = PROXY_FILE_TABLE_COMPATIBILITY_SPECS; + if (!await inspector.tableExists(tableSpec.table)) { + await executeIgnoreDuplicate(inspector, tableSpec.createSql[inspector.dialect]); } else { - for (const spec of COLUMN_COMPATIBILITY_SPECS) { - if (await inspector.columnExists('proxy_files', spec.column)) continue; + for (const spec of PROXY_FILE_COLUMN_COMPATIBILITY_SPECS) { + if (await inspector.columnExists(spec.table, spec.column)) continue; await executeIgnoreDuplicate(inspector, spec.addSql[inspector.dialect]); } } - for (const sqlText of CREATE_INDEX_SQL[inspector.dialect]) { - await executeIgnoreDuplicate(inspector, sqlText); + for (const spec of PROXY_FILE_INDEX_COMPATIBILITY_SPECS) { + await executeIgnoreDuplicate(inspector, spec.createSql[inspector.dialect]); } } diff --git a/src/server/db/routeGroupingSchemaCompatibility.ts b/src/server/db/routeGroupingSchemaCompatibility.ts index 18b6c59..4dacfb3 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.ts @@ -7,18 +7,18 @@ export interface RouteGroupingSchemaInspector { execute(sqlText: string): Promise; } -type RouteGroupingColumnCompatibilitySpec = { +export type RouteGroupingColumnCompatibilitySpec = { table: 'token_routes' | 'route_channels'; column: string; addSql: Record; }; -type RouteGroupingTableCompatibilitySpec = { +export type RouteGroupingTableCompatibilitySpec = { table: 'route_group_sources'; createSql: Record; }; -const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [ +export const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [ { table: 'token_routes', column: 'display_name', @@ -111,7 +111,7 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit }, ]; -const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [ +export const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [ { table: 'route_group_sources', createSql: { diff --git a/src/server/db/siteSchemaCompatibility.ts b/src/server/db/siteSchemaCompatibility.ts index 1ca3ab2..4e08f57 100644 --- a/src/server/db/siteSchemaCompatibility.ts +++ b/src/server/db/siteSchemaCompatibility.ts @@ -7,19 +7,19 @@ export interface SiteSchemaInspector { execute(sqlText: string): Promise; } -type SiteColumnCompatibilitySpec = { +export type SiteColumnCompatibilitySpec = { column: string; addSql: Record; normalizeSql?: Record; }; -type SiteTableCompatibilitySpec = { +export type SiteTableCompatibilitySpec = { table: string; createSql: Record; postCreateSql?: Record; }; -const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ +export const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ { column: 'proxy_url', addSql: { @@ -72,7 +72,7 @@ const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ }, ]; -const SITE_TABLE_COMPATIBILITY_SPECS: SiteTableCompatibilitySpec[] = [ +export const SITE_TABLE_COMPATIBILITY_SPECS: SiteTableCompatibilitySpec[] = [ { table: 'site_disabled_models', createSql: { From f7a7084278f5523d80ce92420205c0b9625559ff Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 13:12:06 +0800 Subject: [PATCH 13/24] refactor: share responsive batch actions across admin pages --- ...ponsiveBatchActionBar.architecture.test.ts | 16 +++++++ .../ResponsiveBatchActionBar.test.tsx | 30 +++++++++++++ .../components/ResponsiveBatchActionBar.tsx | 42 ++++++++++++++++++ src/web/pages/Accounts.tsx | 30 ++++--------- src/web/pages/DownstreamKeys.tsx | 31 +++++-------- src/web/pages/Sites.tsx | 44 ++++--------------- src/web/pages/Tokens.tsx | 27 ++++-------- 7 files changed, 124 insertions(+), 96 deletions(-) create mode 100644 src/web/components/ResponsiveBatchActionBar.architecture.test.ts create mode 100644 src/web/components/ResponsiveBatchActionBar.test.tsx create mode 100644 src/web/components/ResponsiveBatchActionBar.tsx diff --git a/src/web/components/ResponsiveBatchActionBar.architecture.test.ts b/src/web/components/ResponsiveBatchActionBar.architecture.test.ts new file mode 100644 index 0000000..bb120dd --- /dev/null +++ b/src/web/components/ResponsiveBatchActionBar.architecture.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +function readPageSource(relativePath: string): string { + return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n'); +} + +describe('ResponsiveBatchActionBar page adoption', () => { + it('is used by the repeated admin list pages instead of open-coded mobile and desktop batch wrappers', () => { + expect(readPageSource('src/web/pages/Accounts.tsx')).toContain('ResponsiveBatchActionBar'); + expect(readPageSource('src/web/pages/Sites.tsx')).toContain('ResponsiveBatchActionBar'); + expect(readPageSource('src/web/pages/Tokens.tsx')).toContain('ResponsiveBatchActionBar'); + expect(readPageSource('src/web/pages/DownstreamKeys.tsx')).toContain('ResponsiveBatchActionBar'); + }); +}); diff --git a/src/web/components/ResponsiveBatchActionBar.test.tsx b/src/web/components/ResponsiveBatchActionBar.test.tsx new file mode 100644 index 0000000..67562d4 --- /dev/null +++ b/src/web/components/ResponsiveBatchActionBar.test.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { create } from 'react-test-renderer'; +import ResponsiveBatchActionBar from './ResponsiveBatchActionBar.js'; + +describe('ResponsiveBatchActionBar', () => { + it('renders the shared mobile batch bar on mobile', () => { + const root = create( + + + , + ); + + const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar'); + expect(bar).toBeTruthy(); + expect(root.root.findByType('button').children).toContain('批量启用'); + }); + + it('renders the desktop card wrapper on desktop', () => { + const root = create( + + + , + ); + + const card = root.root.find((node) => node.props.className === 'card'); + expect(card).toBeTruthy(); + expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true); + }); +}); diff --git a/src/web/components/ResponsiveBatchActionBar.tsx b/src/web/components/ResponsiveBatchActionBar.tsx new file mode 100644 index 0000000..0ef4a08 --- /dev/null +++ b/src/web/components/ResponsiveBatchActionBar.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import MobileBatchBar from './MobileBatchBar.js'; + +type ResponsiveBatchActionBarProps = { + isMobile: boolean; + info: React.ReactNode; + children: React.ReactNode; + desktopStyle?: React.CSSProperties; + infoStyle?: React.CSSProperties; +}; + +const DEFAULT_DESKTOP_STYLE: React.CSSProperties = { + padding: 12, + display: 'flex', + gap: 8, + flexWrap: 'wrap', + alignItems: 'center', +}; + +const DEFAULT_INFO_STYLE: React.CSSProperties = { + fontSize: 13, + fontWeight: 600, +}; + +export default function ResponsiveBatchActionBar({ + isMobile, + info, + children, + desktopStyle, + infoStyle, +}: ResponsiveBatchActionBarProps) { + if (isMobile) { + return {children}; + } + + return ( +
+ {info} + {children} +
+ ); +} diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index c9ac5bc..3c80459 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -2,9 +2,9 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import CenteredModal from '../components/CenteredModal.js'; -import MobileBatchBar from '../components/MobileBatchBar.js'; import MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js'; +import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; @@ -1066,9 +1066,12 @@ export default function Accounts() { : <>确定要删除选中的 {deleteConfirm?.count || 0} 个连接吗?} /> - {!isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && ( -
- 已选 {selectedAccountIds.length} 项 + {activeSegment !== 'tokens' && selectedAccountIds.length > 0 && ( + @@ -1081,24 +1084,7 @@ export default function Accounts() { -
- )} - - {isMobile && activeSegment !== 'tokens' && selectedAccountIds.length > 0 && ( - - - - - - + )} {activeSegment === 'tokens' ? ( diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx index 83a6868..c2ec3f5 100644 --- a/src/web/pages/DownstreamKeys.tsx +++ b/src/web/pages/DownstreamKeys.tsx @@ -3,9 +3,9 @@ import { createPortal } from 'react-dom'; import { api } from '../api.js'; import CenteredModal from '../components/CenteredModal.js'; import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; -import MobileBatchBar from '../components/MobileBatchBar.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; import MobileFilterSheet from '../components/MobileFilterSheet.js'; +import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; @@ -1687,24 +1687,17 @@ export default function DownstreamKeys() { {selectedIds.length > 0 ? ( - isMobile ? ( - - - - - - - - ) : ( -
- 已选 {selectedIds.length} 个密钥 - - - - - -
- ) + + + + + + + ) : null}
diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index adcf76e..83135cf 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import CenteredModal from '../components/CenteredModal.js'; -import MobileBatchBar from '../components/MobileBatchBar.js'; import MobileFilterSheet from '../components/MobileFilterSheet.js'; +import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; @@ -689,9 +689,12 @@ export default function Sites() {
- {!isMobile && selectedSiteIds.length > 0 && ( -
- 已选 {selectedSiteIds.length} 项 + {selectedSiteIds.length > 0 && ( + -
- )} - - {isMobile && selectedSiteIds.length > 0 && ( - - - - - - - + )}
diff --git a/src/web/pages/Tokens.tsx b/src/web/pages/Tokens.tsx index 442d3d7..41aa776 100644 --- a/src/web/pages/Tokens.tsx +++ b/src/web/pages/Tokens.tsx @@ -2,9 +2,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Navigate, useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import CenteredModal from '../components/CenteredModal.js'; -import MobileBatchBar from '../components/MobileBatchBar.js'; import MobileFilterSheet from '../components/MobileFilterSheet.js'; import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js'; +import ResponsiveBatchActionBar from '../components/ResponsiveBatchActionBar.js'; import { useToast } from '../components/Toast.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { @@ -996,9 +996,12 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token ) : null} - {!isMobile && selectedTokenIds.length > 0 && ( -
- 已选 {selectedTokenIds.length} 项 + {selectedTokenIds.length > 0 && ( + @@ -1008,21 +1011,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token -
- )} - - {isMobile && selectedTokenIds.length > 0 && ( - - - - - + )} Date: Mon, 23 Mar 2026 13:18:37 +0800 Subject: [PATCH 14/24] refactor: extract settings portal modals --- src/web/pages/Settings.tsx | 314 ++---------------- ...s.factory-reset-modal.architecture.test.ts | 12 + ....route-selector-modal.architecture.test.ts | 12 + src/web/pages/settings/FactoryResetModal.tsx | 68 ++++ src/web/pages/settings/RouteSelectorModal.tsx | 307 +++++++++++++++++ 5 files changed, 429 insertions(+), 284 deletions(-) create mode 100644 src/web/pages/settings.factory-reset-modal.architecture.test.ts create mode 100644 src/web/pages/settings.route-selector-modal.architecture.test.ts create mode 100644 src/web/pages/settings/FactoryResetModal.tsx create mode 100644 src/web/pages/settings/RouteSelectorModal.tsx diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index e110590..719c145 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -6,8 +6,9 @@ import { useToast } from '../components/Toast.js'; import { useIsMobile } from '../components/useIsMobile.js'; import ChangeKeyModal from '../components/ChangeKeyModal.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; -import { BrandGlyph, InlineBrandIcon, getBrand } from '../components/BrandIcon.js'; import ModernSelect from '../components/ModernSelect.js'; +import FactoryResetModal from './settings/FactoryResetModal.js'; +import RouteSelectorModal from './settings/RouteSelectorModal.js'; import { applyRoutingProfilePreset, resolveRoutingProfilePreset, @@ -19,8 +20,6 @@ import { clearAppInstallationState } from '../appLocalState.js'; import { tr } from '../i18n.js'; import { isExactModelPattern, - resolveRouteBrand, - resolveRouteIcon, resolveRouteTitle, } from './token-routes/utils.js'; import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js'; @@ -1730,287 +1729,34 @@ export default function Settings() { ); return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; })()} - {factoryResetPresence.shouldRender && (() => { - const confirmLabel = factoryResetting - ? '重新初始化中...' - : (factoryResetSecondsLeft > 0 - ? `确认重新初始化系统(${factoryResetSecondsLeft}s)` - : '确认重新初始化系统'); - const modal = ( -
-
e.stopPropagation()} - > -
确认重新初始化系统
-
-
- 这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。 -
-
-
• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。
-
• 系统随后会强制切回默认 SQLite。
-
• 管理员 Token 将重置为 {FACTORY_RESET_ADMIN_TOKEN}。
-
• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。
-
-
-
- - -
-
-
- ); - return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; - })()} - {selectorModalPresence.shouldRender && (() => { - const modal = ( -
-
e.stopPropagation()} - > -
勾选模型和群组
-
-
- 选择结果会保存到当前下游 API Key:精确模型用于模型白名单,群组用于路由范围限制。 -
- {selectorLoading ? ( -
- - 加载路由中... -
- ) : ( -
-
-
- 精确模型 ({selectorModelSearch.trim() - ? `${filteredExactModelOptions.length}/${exactModelOptions.length}` - : exactModelOptions.length}) -
- setSelectorModelSearch(e.target.value)} - placeholder="搜索精确模型(支持模糊匹配)" - style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }} - /> -
- {exactModelOptions.length === 0 ? ( -
暂无可选精确模型
- ) : filteredExactModelOptions.length === 0 ? ( -
没有匹配的精确模型
- ) : filteredExactModelOptions.map((modelName) => { - const checked = downstreamCreate.selectedModels.includes(modelName); - const brand = getBrand(modelName); - return ( - - ); - })} -
-
- -
-
- 群组 ({selectorGroupSearch.trim() - ? `${filteredGroupRouteOptions.length}/${groupRouteOptions.length}` - : groupRouteOptions.length}) -
- setSelectorGroupSearch(e.target.value)} - placeholder="Search groups (name / pattern)" - style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }} - /> -
- {groupRouteOptions.length === 0 ? ( -
暂无可选群组
- ) : filteredGroupRouteOptions.length === 0 ? ( -
没有匹配的群组
- ) : filteredGroupRouteOptions.map((route) => { - const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id); - const routeTitle = resolveRouteTitle(route); - const routeIcon = resolveRouteIcon(route); - const routeBrand = resolveRouteBrand(route); - return ( - - ); - })} -
-
-
- )} -
-
- -
-
-
- ); - return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; - })()} + +
); } diff --git a/src/web/pages/settings.factory-reset-modal.architecture.test.ts b/src/web/pages/settings.factory-reset-modal.architecture.test.ts new file mode 100644 index 0000000..efe1189 --- /dev/null +++ b/src/web/pages/settings.factory-reset-modal.architecture.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('Settings factory reset modal extraction', () => { + it('delegates the destructive factory reset portal to a dedicated settings component', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n'); + + expect(source).toContain("import FactoryResetModal from './settings/FactoryResetModal.js'"); + expect(source).not.toContain('{factoryResetPresence.shouldRender && (() => {'); + }); +}); diff --git a/src/web/pages/settings.route-selector-modal.architecture.test.ts b/src/web/pages/settings.route-selector-modal.architecture.test.ts new file mode 100644 index 0000000..c8eeb67 --- /dev/null +++ b/src/web/pages/settings.route-selector-modal.architecture.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('Settings route selector modal extraction', () => { + it('delegates the downstream route selector portal to a dedicated settings component', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n'); + + expect(source).toContain("import RouteSelectorModal from './settings/RouteSelectorModal.js'"); + expect(source).not.toContain('{selectorModalPresence.shouldRender && (() => {'); + }); +}); diff --git a/src/web/pages/settings/FactoryResetModal.tsx b/src/web/pages/settings/FactoryResetModal.tsx new file mode 100644 index 0000000..04bca49 --- /dev/null +++ b/src/web/pages/settings/FactoryResetModal.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; + +type ModalPresence = { + shouldRender: boolean; + isVisible: boolean; +}; + +type FactoryResetModalProps = { + presence: ModalPresence; + factoryResetting: boolean; + factoryResetSecondsLeft: number; + adminToken: string; + onClose: () => void; + onConfirm: () => void; +}; + +export default function FactoryResetModal({ + presence, + factoryResetting, + factoryResetSecondsLeft, + adminToken, + onClose, + onConfirm, +}: FactoryResetModalProps) { + if (!presence.shouldRender) { + return null; + } + + const confirmLabel = factoryResetting + ? '重新初始化中...' + : (factoryResetSecondsLeft > 0 + ? `确认重新初始化系统(${factoryResetSecondsLeft}s)` + : '确认重新初始化系统'); + + const modal = ( +
+
e.stopPropagation()} + > +
确认重新初始化系统
+
+
+ 这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。 +
+
+
• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。
+
• 系统随后会强制切回默认 SQLite。
+
• 管理员 Token 将重置为 {adminToken}。
+
• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。
+
+
+
+ + +
+
+
+ ); + + return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; +} diff --git a/src/web/pages/settings/RouteSelectorModal.tsx b/src/web/pages/settings/RouteSelectorModal.tsx new file mode 100644 index 0000000..83b6afd --- /dev/null +++ b/src/web/pages/settings/RouteSelectorModal.tsx @@ -0,0 +1,307 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; +import { BrandGlyph, InlineBrandIcon, getBrand } from '../../components/BrandIcon.js'; +import { + resolveRouteBrand, + resolveRouteIcon, + resolveRouteTitle, +} from '../token-routes/utils.js'; + +type RouteSelectorItem = { + id: number; + modelPattern: string; + displayName?: string | null; + displayIcon?: string | null; + enabled: boolean; +}; + +type ModalPresence = { + shouldRender: boolean; + isVisible: boolean; +}; + +type DownstreamRouteSelection = { + selectedModels: string[]; + selectedGroupRouteIds: number[]; +}; + +type RouteSelectorModalProps = { + presence: ModalPresence; + loading: boolean; + exactModelOptions: string[]; + filteredExactModelOptions: string[]; + groupRouteOptions: RouteSelectorItem[]; + filteredGroupRouteOptions: RouteSelectorItem[]; + selectorModelSearch: string; + selectorGroupSearch: string; + onSelectorModelSearchChange: (value: string) => void; + onSelectorGroupSearchChange: (value: string) => void; + selection: DownstreamRouteSelection; + onToggleModelSelection: (modelName: string) => void; + onToggleGroupRouteSelection: (routeId: number) => void; + onClose: () => void; + inputStyle: React.CSSProperties; +}; + +export default function RouteSelectorModal({ + presence, + loading, + exactModelOptions, + filteredExactModelOptions, + groupRouteOptions, + filteredGroupRouteOptions, + selectorModelSearch, + selectorGroupSearch, + onSelectorModelSearchChange, + onSelectorGroupSearchChange, + selection, + onToggleModelSelection, + onToggleGroupRouteSelection, + onClose, + inputStyle, +}: RouteSelectorModalProps) { + if (!presence.shouldRender) { + return null; + } + + const modal = ( +
+
e.stopPropagation()} + > +
勾选模型和群组
+
+
+ 选择结果会保存到当前下游 API Key:精确模型用于模型白名单,群组用于路由范围限制。 +
+ {loading ? ( +
+ + 加载路由中... +
+ ) : ( +
+
+
+ 精确模型 ({selectorModelSearch.trim() + ? `${filteredExactModelOptions.length}/${exactModelOptions.length}` + : exactModelOptions.length}) +
+ onSelectorModelSearchChange(e.target.value)} + placeholder="搜索精确模型(支持模糊匹配)" + style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }} + /> +
+ {exactModelOptions.length === 0 ? ( +
暂无可选精确模型
+ ) : filteredExactModelOptions.length === 0 ? ( +
没有匹配的精确模型
+ ) : filteredExactModelOptions.map((modelName) => { + const checked = selection.selectedModels.includes(modelName); + const brand = getBrand(modelName); + return ( + + ); + })} +
+
+ +
+
+ 群组 ({selectorGroupSearch.trim() + ? `${filteredGroupRouteOptions.length}/${groupRouteOptions.length}` + : groupRouteOptions.length}) +
+ onSelectorGroupSearchChange(e.target.value)} + placeholder="Search groups (name / pattern)" + style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }} + /> +
+ {groupRouteOptions.length === 0 ? ( +
暂无可选群组
+ ) : filteredGroupRouteOptions.length === 0 ? ( +
没有匹配的群组
+ ) : filteredGroupRouteOptions.map((route) => { + const checked = selection.selectedGroupRouteIds.includes(route.id); + const routeTitle = resolveRouteTitle(route); + const routeIcon = resolveRouteIcon(route); + const routeBrand = resolveRouteBrand(route); + return ( + + ); + })} +
+
+
+ )} +
+
+ +
+
+
+ ); + + return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; +} From c3e3985b6e058f9e247b2e48f36946c4565ef833 Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 13:29:39 +0800 Subject: [PATCH 15/24] refactor: extract remaining settings modals --- src/web/pages/Settings.tsx | 117 +++------------ ...ings.downstream-modal.architecture.test.ts | 12 ++ .../pages/settings/DownstreamApiKeyModal.tsx | 140 ++++++++++++++++++ 3 files changed, 169 insertions(+), 100 deletions(-) create mode 100644 src/web/pages/settings.downstream-modal.architecture.test.ts create mode 100644 src/web/pages/settings/DownstreamApiKeyModal.tsx diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index 719c145..7207eb2 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; import { useNavigate } from 'react-router-dom'; import { api } from '../api.js'; import { useToast } from '../components/Toast.js'; @@ -7,6 +6,7 @@ import { useIsMobile } from '../components/useIsMobile.js'; import ChangeKeyModal from '../components/ChangeKeyModal.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import ModernSelect from '../components/ModernSelect.js'; +import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js'; import FactoryResetModal from './settings/FactoryResetModal.js'; import RouteSelectorModal from './settings/RouteSelectorModal.js'; import { @@ -1630,105 +1630,22 @@ export default function Settings() { - {downstreamModalPresence.shouldRender && (() => { - const modal = ( -
-
e.stopPropagation()} - > -
- {editingDownstreamId ? '编辑下游 API Key' : '新增下游 API Key'} -
-
-
- setDownstreamCreate((prev) => ({ ...prev, name: e.target.value }))} - placeholder="Name (e.g. cc-project)" - style={inputStyle} - /> - setDownstreamCreate((prev) => ({ ...prev, key: e.target.value.trim() }))} - placeholder="sk-xxxx" - style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} - /> - setDownstreamCreate((prev) => ({ ...prev, maxCost: e.target.value }))} - placeholder="最大费用(可选)" - type="number" - min={0} - step={0.000001} - style={inputStyle} - /> - setDownstreamCreate((prev) => ({ ...prev, maxRequests: e.target.value }))} - placeholder="最大请求数(可选)" - type="number" - min={0} - step={1} - style={inputStyle} - /> - setDownstreamCreate((prev) => ({ ...prev, expiresAt: e.target.value }))} - type="datetime-local" - placeholder="过期时间(可选)" - style={inputStyle} - /> - setDownstreamCreate((prev) => ({ ...prev, description: e.target.value }))} - placeholder="备注(可选)" - style={inputStyle} - /> -
- -
-
- 已选模型 {downstreamCreate.selectedModels.length} 个,已选群组 {downstreamCreate.selectedGroupRouteIds.length} 个 -
-
- - {(downstreamCreate.selectedModels.length > 0 || downstreamCreate.selectedGroupRouteIds.length > 0) && ( - - )} -
-
-
-
- - -
-
-
- ); - return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; - })()} + setDownstreamCreate((prev) => updater(prev))} + onOpenSelector={async () => { + if (selectorRoutes.length === 0) await loadRouteSelectorRoutes(); + setSelectorModelSearch(''); + setSelectorGroupSearch(''); + setSelectorOpen(true); + }} + onClose={closeDownstreamModal} + onSave={saveDownstreamKey} + /> { + it('delegates the downstream API key portal to a dedicated settings component', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Settings.tsx'), 'utf8').replace(/\r\n/g, '\n'); + + expect(source).toContain("import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js'"); + expect(source).not.toContain('{downstreamModalPresence.shouldRender && (() => {'); + }); +}); diff --git a/src/web/pages/settings/DownstreamApiKeyModal.tsx b/src/web/pages/settings/DownstreamApiKeyModal.tsx new file mode 100644 index 0000000..408833c --- /dev/null +++ b/src/web/pages/settings/DownstreamApiKeyModal.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; + +type ModalPresence = { + shouldRender: boolean; + isVisible: boolean; +}; + +type DownstreamCreateForm = { + name: string; + key: string; + description: string; + maxCost: string; + maxRequests: string; + expiresAt: string; + selectedModels: string[]; + selectedGroupRouteIds: number[]; +}; + +type DownstreamApiKeyModalProps = { + presence: ModalPresence; + editingDownstreamId: number | null; + downstreamCreate: DownstreamCreateForm; + downstreamSaving: boolean; + inputStyle: React.CSSProperties; + onChange: (updater: (prev: DownstreamCreateForm) => DownstreamCreateForm) => void; + onOpenSelector: () => Promise | void; + onClose: () => void; + onSave: () => Promise | void; +}; + +export default function DownstreamApiKeyModal({ + presence, + editingDownstreamId, + downstreamCreate, + downstreamSaving, + inputStyle, + onChange, + onOpenSelector, + onClose, + onSave, +}: DownstreamApiKeyModalProps) { + if (!presence.shouldRender) { + return null; + } + + const modal = ( +
+
e.stopPropagation()} + > +
+ {editingDownstreamId ? '编辑下游 API Key' : '新增下游 API Key'} +
+
+
+ onChange((prev) => ({ ...prev, name: e.target.value }))} + placeholder="Name (e.g. cc-project)" + style={inputStyle} + /> + onChange((prev) => ({ ...prev, key: e.target.value.trim() }))} + placeholder="sk-xxxx" + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + /> + onChange((prev) => ({ ...prev, maxCost: e.target.value }))} + placeholder="最大费用(可选)" + type="number" + min={0} + step={0.000001} + style={inputStyle} + /> + onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} + placeholder="最大请求数(可选)" + type="number" + min={0} + step={1} + style={inputStyle} + /> + onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} + type="datetime-local" + placeholder="过期时间(可选)" + style={inputStyle} + /> + onChange((prev) => ({ ...prev, description: e.target.value }))} + placeholder="备注(可选)" + style={inputStyle} + /> +
+ +
+
+ 已选模型 {downstreamCreate.selectedModels.length} 个,已选群组 {downstreamCreate.selectedGroupRouteIds.length} 个 +
+
+ + {(downstreamCreate.selectedModels.length > 0 || downstreamCreate.selectedGroupRouteIds.length > 0) ? ( + + ) : null} +
+
+
+
+ + +
+
+
+ ); + + return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; +} From 9b21ed3fedf46965c597665aeacbcff877737f07 Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 13:32:55 +0800 Subject: [PATCH 16/24] refactor: extract account model management modal --- src/web/pages/Accounts.tsx | 220 ++--------------- .../accounts.model-modal.architecture.test.ts | 12 + src/web/pages/accounts/AccountModelsModal.tsx | 231 ++++++++++++++++++ 3 files changed, 261 insertions(+), 202 deletions(-) create mode 100644 src/web/pages/accounts.model-modal.architecture.test.ts create mode 100644 src/web/pages/accounts/AccountModelsModal.tsx diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 3c80459..78ae898 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -11,6 +11,7 @@ import { MobileCard, MobileField } from '../components/MobileCard.js'; import { useIsMobile } from '../components/useIsMobile.js'; import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; import SiteBadgeLink from '../components/SiteBadgeLink.js'; +import AccountModelsModal from './accounts/AccountModelsModal.js'; import { buildAddAccountPrereqHint, buildVerifyFailureHint, @@ -2006,209 +2007,24 @@ export default function Accounts() { )} - {/* Model management modal */} - - - - - } - > - {modelModal.loading ? ( -
- - 加载模型列表... -
- ) : ( -
- {modelModal.models.length === 0 ? ( -
-
🤖
-
暂无可用模型
-
请先点击账号操作栏中的「刷新」或「模型」按钮获取模型
- -
- ) : ( - <> -
- -
- - - - -
-
- -
- {modelModal.models.map((model, idx) => { - const isDisabled = modelModal.pendingDisabled.has(model.name); - return ( - - ); - })} -
-
- 💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。 -
- - )} - -
-
手动添加可用模型
-
- 如果您的账号支持某些未在上方列表中显示的模型,可以在此手动添加(多个以英文逗号分隔)。 -
-
- setModelModal(s => ({ ...s, manualModelsInput: e.target.value }))} - style={{ ...inputStyle, flex: 1, fontFamily: 'var(--font-mono)' }} - onKeyDown={(e) => { - if (e.key === 'Enter' && !modelModal.addingManualModels) { - handleAddManualModels(); - } - }} - /> - -
-
-
- )} -
+ onSave={saveModelDisabled} + onRefresh={async () => { + if (!modelModal.account) return; + await loadModelModalModels(modelModal.account, { + refreshUpstream: true, + successMessage: '模型列表已刷新', + errorMessage: '刷新失败', + }); + }} + onToggleModelDisabled={toggleModelDisabled} + onSetPendingDisabled={(pendingDisabled) => setModelModal((state) => ({ ...state, pendingDisabled }))} + onManualInputChange={(value) => setModelModal((state) => ({ ...state, manualModelsInput: value }))} + onAddManualModels={handleAddManualModels} + /> ); } diff --git a/src/web/pages/accounts.model-modal.architecture.test.ts b/src/web/pages/accounts.model-modal.architecture.test.ts new file mode 100644 index 0000000..4dd73cf --- /dev/null +++ b/src/web/pages/accounts.model-modal.architecture.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('Accounts model modal extraction', () => { + it('delegates the account model management modal to a dedicated accounts component', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Accounts.tsx'), 'utf8').replace(/\r\n/g, '\n'); + + expect(source).toContain("import AccountModelsModal from './accounts/AccountModelsModal.js'"); + expect(source).not.toContain('open={modelModal.open}'); + }); +}); diff --git a/src/web/pages/accounts/AccountModelsModal.tsx b/src/web/pages/accounts/AccountModelsModal.tsx new file mode 100644 index 0000000..a530262 --- /dev/null +++ b/src/web/pages/accounts/AccountModelsModal.tsx @@ -0,0 +1,231 @@ +import React from 'react'; +import CenteredModal from '../../components/CenteredModal.js'; + +type AccountModelRow = { + name: string; + latencyMs: number | null; + disabled: boolean; + isManual?: boolean; +}; + +type AccountModelModalState = { + open: boolean; + account: any | null; + models: AccountModelRow[]; + pendingDisabled: Set; + loading: boolean; + saving: boolean; + siteName: string; + manualModelsInput: string; + addingManualModels: boolean; +}; + +type AccountModelsModalProps = { + modelModal: AccountModelModalState; + inputStyle: React.CSSProperties; + onClose: () => void; + onSave: () => void; + onRefresh: () => Promise | void; + onToggleModelDisabled: (modelName: string) => void; + onSetPendingDisabled: (pendingDisabled: Set) => void; + onManualInputChange: (value: string) => void; + onAddManualModels: () => Promise | void; +}; + +export default function AccountModelsModal({ + modelModal, + inputStyle, + onClose, + onSave, + onRefresh, + onToggleModelDisabled, + onSetPendingDisabled, + onManualInputChange, + onAddManualModels, +}: AccountModelsModalProps) { + return ( + + + + + )} + > + {modelModal.loading ? ( +
+ + 加载模型列表... +
+ ) : ( +
+ {modelModal.models.length === 0 ? ( +
+
🤖
+
暂无可用模型
+
请先点击账号操作栏中的「刷新」或「模型」按钮获取模型
+ +
+ ) : ( + <> +
+ +
+ + + + +
+
+ +
+ {modelModal.models.map((model, idx) => { + const isDisabled = modelModal.pendingDisabled.has(model.name); + return ( + + ); + })} +
+
+ 💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。 +
+ + )} + +
+
手动添加可用模型
+
+ 如果您的账号支持某些未在上方列表中显示的模型,可以在此手动添加(多个以英文逗号分隔)。 +
+
+ onManualInputChange(e.target.value)} + style={{ ...inputStyle, flex: 1, fontFamily: 'var(--font-mono)' }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !modelModal.addingManualModels) { + void onAddManualModels(); + } + }} + /> + +
+
+
+ )} +
+ ); +} From 8f145405491267999229b757d774fa890d97b95b Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 13:35:42 +0800 Subject: [PATCH 17/24] refactor: extract model tester debug panel --- src/web/pages/ModelTester.tsx | 106 ++----------- src/web/pages/model-tester/DebugPanel.tsx | 144 ++++++++++++++++++ ...elTester.debug-panel.architecture.test.tsx | 12 ++ 3 files changed, 166 insertions(+), 96 deletions(-) create mode 100644 src/web/pages/model-tester/DebugPanel.tsx create mode 100644 src/web/pages/modelTester.debug-panel.architecture.test.tsx diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index 64de6e3..f3ae2d5 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -53,6 +53,7 @@ import { isConversationUploadedFileSupported, resolveConversationFileCapability, } from './helpers/conversationFileCapabilities.js'; +import DebugPanel from './model-tester/DebugPanel.js'; import ModernSelect from '../components/ModernSelect.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useIsMobile } from '../components/useIsMobile.js'; @@ -3104,102 +3105,15 @@ export default function ModelTester() { - {debugPanelPresence.shouldRender && ( -
-
-

调试

-
- {debugTimestamp ? new Date(debugTimestamp).toLocaleString() : '--'} -
-
- -
- - - -
- -
-
-                {debugTabContent || '// 暂无数据'}
-              
-
- -
时间线
-
- {debugTimeline.length === 0 ? ( -
暂无事件。
- ) : ( -
- {debugTimeline.map((item, index) => ( -
- - {item.level} - - - {new Date(item.at).toLocaleTimeString()} - - {item.text} -
- ))} -
- )} -
-
- )} + ); diff --git a/src/web/pages/model-tester/DebugPanel.tsx b/src/web/pages/model-tester/DebugPanel.tsx new file mode 100644 index 0000000..69705ee --- /dev/null +++ b/src/web/pages/model-tester/DebugPanel.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { DEBUG_TABS, type DebugTab } from '../helpers/modelTesterSession.js'; + +type DebugTimelineEntry = { + at: string; + level: 'info' | 'warn' | 'error'; + text: string; +}; + +type DebugPanelPresence = { + shouldRender: boolean; + isVisible: boolean; +}; + +type DebugPanelProps = { + presence: DebugPanelPresence; + isMobile: boolean; + debugTimestamp: number | null; + activeDebugTab: DebugTab; + onTabChange: (tab: DebugTab) => void; + debugTabContent: string; + debugTimeline: DebugTimelineEntry[]; +}; + +export default function DebugPanel({ + presence, + isMobile, + debugTimestamp, + activeDebugTab, + onTabChange, + debugTabContent, + debugTimeline, +}: DebugPanelProps) { + if (!presence.shouldRender) { + return null; + } + + return ( +
+
+

调试

+
+ {debugTimestamp ? new Date(debugTimestamp).toLocaleString() : '--'} +
+
+ +
+ + + +
+ +
+
+          {debugTabContent || '// 暂无数据'}
+        
+
+ +
时间线
+
+ {debugTimeline.length === 0 ? ( +
暂无事件。
+ ) : ( +
+ {debugTimeline.map((item, index) => ( +
+ + {item.level} + + + {new Date(item.at).toLocaleTimeString()} + + {item.text} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/web/pages/modelTester.debug-panel.architecture.test.tsx b/src/web/pages/modelTester.debug-panel.architecture.test.tsx new file mode 100644 index 0000000..6d6ad18 --- /dev/null +++ b/src/web/pages/modelTester.debug-panel.architecture.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('ModelTester debug panel extraction', () => { + it('delegates the debug sidebar to a dedicated model-tester component', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/pages/ModelTester.tsx'), 'utf8').replace(/\r\n/g, '\n'); + + expect(source).toContain("import DebugPanel from './model-tester/DebugPanel.js'"); + expect(source).not.toContain('时间线'); + }); +}); From 8e001508bf3d8ccbdbb58634e058a92fd0cd6e27 Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 23 Mar 2026 13:44:52 +0800 Subject: [PATCH 18/24] refactor: extract model tester composer and tighten shared types --- src/server/db/schemaContract.ts | 2 + .../proxy-core/surfaces/sharedSurface.ts | 42 +++- src/web/pages/ModelTester.tsx | 177 ++------------- .../model-tester/ConversationComposer.tsx | 207 ++++++++++++++++++ ...onversation-composer.architecture.test.tsx | 12 + 5 files changed, 279 insertions(+), 161 deletions(-) create mode 100644 src/web/pages/model-tester/ConversationComposer.tsx create mode 100644 src/web/pages/modelTester.conversation-composer.architecture.test.tsx diff --git a/src/server/db/schemaContract.ts b/src/server/db/schemaContract.ts index b241e84..00303a8 100644 --- a/src/server/db/schemaContract.ts +++ b/src/server/db/schemaContract.ts @@ -9,6 +9,8 @@ import { type LogicalColumnType, } from './schemaMetadata.js'; +export type { LogicalColumnType } from './schemaMetadata.js'; + export interface SchemaContractColumn { logicalType: LogicalColumnType; notNull: boolean; diff --git a/src/server/proxy-core/surfaces/sharedSurface.ts b/src/server/proxy-core/surfaces/sharedSurface.ts index f1f91b9..efad78b 100644 --- a/src/server/proxy-core/surfaces/sharedSurface.ts +++ b/src/server/proxy-core/surfaces/sharedSurface.ts @@ -59,8 +59,23 @@ type SurfaceOauthRefreshContext = { }; type SurfaceSuccessSelectedChannel = SurfaceSelectedChannel & { - account: Record & { id: number }; - site: Record; + account: Record & { + id: number; + username?: string | null; + accessToken?: string | null; + apiToken?: string | null; + extraConfig?: string | null; + platformUserId?: number | null; + }; + site: Record & { + id: number; + url: string; + platform: string; + apiKey?: string | null; + useSystemProxy?: boolean | null; + proxyUrl?: string | null; + name?: string | null; + }; tokenValue: string; tokenName?: string | null; }; @@ -69,6 +84,18 @@ type SurfaceUsageSummary = { promptTokens: number; completionTokens: number; totalTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + promptTokensIncludeCache: boolean | null; +}; + +type SurfaceResolvedUsageSummary = { + promptTokens: number; + completionTokens: number; + totalTokens: number; + recoveredFromSelfLog: boolean; + estimatedCostFromQuota: number; + selfLogBillingMeta: import('../../services/proxyUsageFallbackService.js').SelfLogBillingMeta | null; }; export async function selectSurfaceChannelForAttempt(input: { @@ -251,11 +278,18 @@ export async function recordSurfaceSuccess(input: { bestEffortMetrics?: { errorLabel: string; }; -}) { - let resolvedUsage = { +}): Promise<{ + resolvedUsage: SurfaceResolvedUsageSummary; + estimatedCost: number; + billingDetails: unknown; +}> { + let resolvedUsage: SurfaceResolvedUsageSummary = { promptTokens: input.parsedUsage.promptTokens, completionTokens: input.parsedUsage.completionTokens, totalTokens: input.parsedUsage.totalTokens, + recoveredFromSelfLog: false, + estimatedCostFromQuota: 0, + selfLogBillingMeta: null, }; let estimatedCost = 0; let billingDetails: unknown = null; diff --git a/src/web/pages/ModelTester.tsx b/src/web/pages/ModelTester.tsx index f3ae2d5..e017b27 100644 --- a/src/web/pages/ModelTester.tsx +++ b/src/web/pages/ModelTester.tsx @@ -53,6 +53,7 @@ import { isConversationUploadedFileSupported, resolveConversationFileCapability, } from './helpers/conversationFileCapabilities.js'; +import ConversationComposer from './model-tester/ConversationComposer.js'; import DebugPanel from './model-tester/DebugPanel.js'; import ModernSelect from '../components/ModernSelect.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; @@ -2859,163 +2860,25 @@ export default function ModelTester() { )} {inputs.mode === 'conversation' ? ( -
-
-
- { - void handleConversationFilesChange(event.target.files); - event.target.value = ''; - }} - /> -
- - - {customRequestMode - ? '自定义请求模式不会自动上传这些附件;关闭自定义模式后可走标准 /v1/files 链路。' - : !conversationFileSupported - ? (conversationFileCapability.reason || '当前协议暂不支持会话附件注入。') - : conversationFileHint} - -
- {conversationFiles.length > 0 && ( -
- {conversationFiles.map((file) => { - const statusText = file.status === 'uploading' - ? '上传中' - : file.status === 'uploaded' - ? '已上传' - : file.status === 'error' - ? '失败' - : '待上传'; - const statusColor = file.status === 'error' - ? 'var(--color-danger)' - : file.status === 'uploaded' - ? 'var(--color-success)' - : file.status === 'uploading' - ? 'var(--color-warning)' - : 'var(--color-text-muted)'; - - return ( - - 📎 - - {file.name} - - · {statusText} - {!sending && ( - - )} - - ); - })} -
- )} -
- -