forked from boos/BoosAPI
refactor: consolidate single-source routing and protocol helpers
This commit is contained in:
@@ -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 <savepoint-commit>
|
||||
```
|
||||
|
||||
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`
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>,
|
||||
): 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<string, unknown>): boolean {
|
||||
return isCodexRequest({
|
||||
downstreamPath: '/v1/responses',
|
||||
|
||||
@@ -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<string, unknown> | Record<string, string> | 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<string>();
|
||||
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<string, string>;
|
||||
claudeHeaders: Record<string, string>;
|
||||
anthropicVersion: string;
|
||||
stream: boolean;
|
||||
isClaudeOauthUpstream: boolean;
|
||||
tokenValue: string;
|
||||
}): Record<string, string> {
|
||||
const anthropicBeta = mergeClaudeBetaHeader(
|
||||
getInputHeader(input.claudeHeaders, 'anthropic-beta'),
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
...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',
|
||||
|
||||
@@ -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<string, unknown> | Record<string, string> | 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<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
oauthProvider?: string;
|
||||
explicitSessionId?: string | null;
|
||||
continuityKey?: string | null;
|
||||
}): Record<string, string> {
|
||||
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;
|
||||
export const codexProviderProfile: ProviderProfile = {
|
||||
id: 'codex',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
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 configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : '';
|
||||
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 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
|
||||
|
||||
@@ -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<string, unknown> | Record<string, string> | 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<string, string> = {
|
||||
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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | Record<string, string> | 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<string>();
|
||||
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<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
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<string, string> {
|
||||
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<string, string>;
|
||||
claudeHeaders: Record<string, string>;
|
||||
anthropicVersion: string;
|
||||
stream: boolean;
|
||||
isClaudeOauthUpstream: boolean;
|
||||
tokenValue: string;
|
||||
extraBetas?: string[];
|
||||
defaultBetaHeader?: string;
|
||||
defaultUserAgent?: string;
|
||||
}): Record<string, string> {
|
||||
const anthropicBeta = mergeClaudeBetaHeader(
|
||||
getInputHeader(input.claudeHeaders, 'anthropic-beta'),
|
||||
input.defaultBetaHeader || CLAUDE_DEFAULT_BETA_HEADER,
|
||||
input.extraBetas,
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
...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<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
modelName: string;
|
||||
stream: boolean;
|
||||
}): Record<string, string> {
|
||||
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<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
})
|
||||
);
|
||||
@@ -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(
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
}),
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
providerHeaders: buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
account: selected.account,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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, unknown>)
|
||||
? 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<string, unknown>,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<unknown> {
|
||||
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';
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
@@ -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<string, unknown>,
|
||||
})
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof tokenRouter.selectChannel>>;
|
||||
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<SelectedChannel> {
|
||||
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<void> {
|
||||
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<SurfaceFailureOutcome> {
|
||||
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<SurfaceFailureOutcome> {
|
||||
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<SurfaceFailureOutcome> {
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
|
||||
| { 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<SyncExe
|
||||
|
||||
if (!row.accounts.accessToken) {
|
||||
if (row.accounts.apiToken) {
|
||||
ensureDefaultTokenForAccount(accountId, row.accounts.apiToken, { name: 'default', source: 'legacy' });
|
||||
await convergeAccountMutation({
|
||||
accountId,
|
||||
preferredApiToken: row.accounts.apiToken,
|
||||
defaultTokenSource: 'legacy',
|
||||
});
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
@@ -296,7 +297,11 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
||||
};
|
||||
}
|
||||
|
||||
const synced = await syncTokensFromUpstream(accountId, tokens);
|
||||
const convergence = await convergeAccountMutation({
|
||||
accountId,
|
||||
upstreamTokens: tokens,
|
||||
});
|
||||
const synced = convergence.tokenSync!;
|
||||
if ((synced.maskedPending || 0) > 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),
|
||||
));
|
||||
|
||||
if (uniqueAccountIds.length === 0) {
|
||||
return { refresh: [], rebuild: null };
|
||||
}
|
||||
|
||||
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}`);
|
||||
const result = await refreshAccountCoverageBatch({
|
||||
accountIds,
|
||||
batchSize: SYNC_ALL_BATCH_SIZE,
|
||||
mapFailure: buildCoverageRefreshFailureItem,
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
let rebuild: CoverageRefreshRebuildResult | null = null;
|
||||
try {
|
||||
rebuild = {
|
||||
success: true,
|
||||
result: await rebuildTokenRoutesFromAvailability(),
|
||||
return {
|
||||
refresh: result.refresh as CoverageRefreshItem[],
|
||||
rebuild: result.rebuild as CoverageRefreshRebuildResult | null,
|
||||
};
|
||||
} 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 };
|
||||
}
|
||||
|
||||
function buildCoverageRefreshFailureItem(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
]),
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
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<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown> | Record<string, string> | 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<string>();
|
||||
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<string, unknown>): {
|
||||
body: Record<string, unknown>;
|
||||
betas: string[];
|
||||
@@ -327,80 +239,6 @@ function extractClaudeBetasFromBody(body: Record<string, unknown>): {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
explicitSessionId?: string | null;
|
||||
continuityKey?: string | null;
|
||||
}): Record<string, string> {
|
||||
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<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
modelName: string;
|
||||
stream: boolean;
|
||||
}): Record<string, string> {
|
||||
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<string, string> = {
|
||||
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<string, string>;
|
||||
stream: boolean;
|
||||
@@ -414,47 +252,6 @@ function buildAntigravityRuntimeHeaders(input: {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildClaudeRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
claudeHeaders: Record<string, string>;
|
||||
anthropicVersion: string;
|
||||
stream: boolean;
|
||||
isClaudeOauthUpstream: boolean;
|
||||
tokenValue: string;
|
||||
extraBetas?: string[];
|
||||
}): Record<string, string> {
|
||||
const anthropicBeta = mergeClaudeBetaHeader(
|
||||
getInputHeader(input.claudeHeaders, 'anthropic-beta'),
|
||||
input.extraBetas,
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
...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<string, string>,
|
||||
stream: boolean,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
|
||||
| { 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<ReturnType<typeof syncTokensFromUpstream>> | null;
|
||||
refreshedBalance: boolean;
|
||||
refreshedModels: boolean;
|
||||
rebuiltRoutes: boolean;
|
||||
balanceResult: Awaited<ReturnType<typeof refreshBalance>> | null;
|
||||
modelRefreshResult: ModelRefreshResult | null;
|
||||
rebuildResult: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> | null;
|
||||
}> {
|
||||
const result = {
|
||||
defaultTokenId: null as number | null,
|
||||
tokenSync: null as Awaited<ReturnType<typeof syncTokensFromUpstream>> | null,
|
||||
refreshedBalance: false,
|
||||
refreshedModels: false,
|
||||
rebuiltRoutes: false,
|
||||
balanceResult: null as Awaited<ReturnType<typeof refreshBalance>> | null,
|
||||
modelRefreshResult: null as ModelRefreshResult | null,
|
||||
rebuildResult: null as Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> | null,
|
||||
};
|
||||
|
||||
const runStep = async <T>(fn: () => Promise<T>): Promise<T | null> => {
|
||||
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<TFailure>(input: {
|
||||
accountIds: number[];
|
||||
batchSize: number;
|
||||
mapFailure: (accountId: number, errorMessage: string) => TFailure;
|
||||
}): Promise<{
|
||||
refresh: Array<ModelRefreshResult | TFailure>;
|
||||
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<ModelRefreshResult | TFailure> = [];
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<AccountRow, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' | 'extraConfig'>) {
|
||||
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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,15 @@ export type OauthInfo = {
|
||||
lastDiscoveredModels?: string[];
|
||||
};
|
||||
|
||||
export type StoredOauthState = Omit<OauthInfo, 'provider' | 'accountId' | 'accountKey' | 'projectId'>;
|
||||
|
||||
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<OauthInfo> | 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<OauthInfo> = {},
|
||||
@@ -152,6 +199,47 @@ export function buildOauthInfo(
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildOauthInfoFromAccount(
|
||||
account?: OauthIdentityCarrier | null,
|
||||
patch: Partial<OauthInfo> = {},
|
||||
): 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<OauthInfo> = {},
|
||||
): StoredOauthState {
|
||||
return buildStoredOauthState(buildOauthInfoFromAccount(account, patch));
|
||||
}
|
||||
|
||||
export function isOauthProvider(
|
||||
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
|
||||
provider?: string,
|
||||
|
||||
@@ -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<Oaut
|
||||
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');
|
||||
}
|
||||
@@ -236,7 +240,7 @@ export async function recordOauthQuotaResetHint(input: {
|
||||
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get();
|
||||
if (!account) return null;
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
const oauth = getOauthInfoFromAccount(account);
|
||||
if (!oauth || oauth.provider !== 'codex') return null;
|
||||
|
||||
const baseSnapshot = buildQuotaSnapshotFromOauthInfo({
|
||||
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
listOAuthProviderDefinitions,
|
||||
type OAuthProviderDefinition,
|
||||
} from './providers.js';
|
||||
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
||||
import {
|
||||
buildOauthInfo,
|
||||
buildOauthInfoFromAccount,
|
||||
buildStoredOauthState,
|
||||
buildStoredOauthStateFromAccount,
|
||||
getOauthInfoFromAccount,
|
||||
} from './oauthAccount.js';
|
||||
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
||||
|
||||
@@ -232,7 +238,7 @@ async function upsertOauthAccount(input: {
|
||||
});
|
||||
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
|
||||
credentialMode: 'session',
|
||||
oauth,
|
||||
oauth: buildStoredOauthState(oauth),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
@@ -511,7 +517,7 @@ export async function listOauthConnections(options: {
|
||||
}
|
||||
|
||||
const items = rows.map((row) => {
|
||||
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<string, unknown>;
|
||||
}) {
|
||||
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({
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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<UserInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return { success: false, message: 'checkin endpoint not supported' };
|
||||
}
|
||||
|
||||
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||
// 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<string[]> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(resolveClaudeModelsUrl(baseUrl), {
|
||||
return this.fetchModelsFromStandardEndpoint({
|
||||
baseUrl,
|
||||
headers: {
|
||||
'x-api-key': apiToken,
|
||||
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
|
||||
},
|
||||
});
|
||||
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<UserInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return { success: false, message: 'CLIProxyAPI does not support checkin' };
|
||||
}
|
||||
|
||||
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||
return { balance: 0, used: 0, quota: 0 };
|
||||
}
|
||||
|
||||
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||
try {
|
||||
const base = normalizeBaseUrl(baseUrl);
|
||||
const res = await this.fetchJson<any>(`${base}/v1/models`, {
|
||||
return this.fetchModelsFromStandardEndpoint({
|
||||
baseUrl,
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
resolveUrl: resolveVersionedModelsUrl,
|
||||
});
|
||||
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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<UserInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return { success: false, message: 'checkin endpoint not supported' };
|
||||
}
|
||||
|
||||
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||
// 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<string[]> {
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const normalizedBase = normalizePlatformBaseUrl(baseUrl);
|
||||
|
||||
if (isOpenAiCompatGeminiBase(normalizedBase)) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(resolveGeminiOpenAiModelsUrl(normalizedBase), {
|
||||
const openAiModels = await this.fetchModelsFromStandardEndpoint({
|
||||
baseUrl: normalizedBase,
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
resolveUrl: resolveGeminiOpenAiModelsUrl,
|
||||
});
|
||||
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
|
||||
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -92,13 +70,11 @@ export class GeminiAdapter extends BasePlatformAdapter {
|
||||
} catch {}
|
||||
|
||||
if (!isOpenAiCompatGeminiBase(normalizedBase)) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${normalizedBase}/v1beta/openai/models`, {
|
||||
const openAiModels = await this.fetchModelsFromStandardEndpoint({
|
||||
baseUrl: `${normalizedBase}/v1beta/openai`,
|
||||
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 {}
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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<UserInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return { success: false, message: 'checkin endpoint not supported' };
|
||||
}
|
||||
|
||||
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||
// 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<string[]> {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(resolveOpenAiModelsUrl(baseUrl), {
|
||||
return this.fetchModelsFromStandardEndpoint({
|
||||
baseUrl,
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
});
|
||||
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getModels(_baseUrl: string, _token: string): Promise<string[]> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
BasePlatformAdapter,
|
||||
type BalanceInfo,
|
||||
type CheckinResult,
|
||||
type UserInfo,
|
||||
} from './base.js';
|
||||
|
||||
type FetchModelsOptions = {
|
||||
baseUrl: string;
|
||||
headers?: Record<string, string>;
|
||||
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<UserInfo | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
override async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||
return {
|
||||
success: false,
|
||||
message: this.checkinUnsupportedMessage,
|
||||
};
|
||||
}
|
||||
|
||||
override async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||
return { balance: 0, used: 0, quota: 0 };
|
||||
}
|
||||
|
||||
protected async fetchModelsFromStandardEndpoint(options: FetchModelsOptions): Promise<string[]> {
|
||||
const normalizedBaseUrl = normalizePlatformBaseUrl(options.baseUrl);
|
||||
const url = options.resolveUrl
|
||||
? options.resolveUrl(normalizedBaseUrl)
|
||||
: resolveVersionedModelsUrl(normalizedBaseUrl);
|
||||
|
||||
try {
|
||||
const payload = await this.fetchJson<any>(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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<unknown> {
|
||||
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<string, unknown>,
|
||||
owner: ProxyResourceOwner,
|
||||
|
||||
@@ -44,7 +44,7 @@ type ParsedSiteProxyInput = {
|
||||
proxyUrl: string | null;
|
||||
};
|
||||
|
||||
type SiteProxyConfigLike = {
|
||||
export type SiteProxyConfigLike = {
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
customHeaders?: string | null;
|
||||
|
||||
@@ -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<T extends { channel: FailureAware
|
||||
return healthy.length > 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;
|
||||
export type RouteDecisionExplanation = RouteDecision & {
|
||||
routeId?: number;
|
||||
modelPattern?: string;
|
||||
selectedChannelId?: number;
|
||||
selectedAccountId?: number;
|
||||
selectedLabel?: string;
|
||||
summary: string[];
|
||||
candidates: RouteDecisionCandidate[];
|
||||
}
|
||||
};
|
||||
|
||||
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('(?<!') || body.includes('(?<')) {
|
||||
return false;
|
||||
}
|
||||
if (/(^|[^\\])\\[1-9]/.test(body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const groupStack: Array<{ hasInnerQuantifier: boolean; hasAlternation: boolean }> = [];
|
||||
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<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): 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;
|
||||
|
||||
@@ -54,61 +54,6 @@ function validateSystemPrompts(body: Record<string, unknown>): { statusCode: num
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateAdaptiveEffort(body: Record<string, unknown>): { 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<string, unknown>): { 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<string, unknown>,
|
||||
): { sanitizedBody?: Record<string, unknown>; 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,
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> => !!item);
|
||||
return normalized.length > 0 ? normalized : content;
|
||||
}
|
||||
|
||||
const single = normalizeResponsesContentItem(content, role);
|
||||
if (single) return [single];
|
||||
return content;
|
||||
}
|
||||
|
||||
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown>[] {
|
||||
@@ -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<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
||||
if (!model) return null;
|
||||
if (body.input === undefined) return null;
|
||||
|
||||
const core: Record<string, unknown> = {
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> | 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<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
const rawMessages = Array.isArray(openaiBody.messages) ? openaiBody.messages : [];
|
||||
const systemContents: string[] = [];
|
||||
const inputItems: Array<Record<string, unknown>> = [];
|
||||
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>,
|
||||
modelName: string,
|
||||
stream: boolean,
|
||||
): Record<string, unknown> {
|
||||
let normalized: Record<string, unknown> = {
|
||||
...body,
|
||||
model: modelName,
|
||||
stream,
|
||||
};
|
||||
|
||||
if (normalized.input === undefined) {
|
||||
if (Array.isArray((normalized as Record<string, unknown>).messages)) {
|
||||
normalized = convertOpenAiBodyToResponsesBody(normalized, modelName, stream);
|
||||
} else {
|
||||
const prompt = asTrimmedString((normalized as Record<string, unknown>).prompt);
|
||||
if (prompt) {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: [toResponsesInputMessageFromText(prompt)],
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
normalized = {
|
||||
...normalized,
|
||||
input: normalizeResponsesInputForCompatibility(normalized.input),
|
||||
};
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return {
|
||||
type: 'message',
|
||||
@@ -366,17 +363,7 @@ function normalizeResponsesBodyForCompatibility(
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
|
||||
const normalized = normalizeResponsesMessageItem({
|
||||
type: 'message',
|
||||
role,
|
||||
content,
|
||||
});
|
||||
|
||||
if (isRecord(normalized) && Array.isArray(normalized.content)) {
|
||||
return normalized.content.filter((item): item is Record<string, unknown> => 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 };
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> => !!item);
|
||||
return normalized.length > 0 ? normalized : content;
|
||||
}
|
||||
|
||||
const single = normalizeResponsesContentItem(content, role);
|
||||
if (single) return [single];
|
||||
return content;
|
||||
}
|
||||
|
||||
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||
return {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||
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<Record<string, unknown>> {
|
||||
const normalized = normalizeResponsesMessageItem({
|
||||
type: 'message',
|
||||
role,
|
||||
content,
|
||||
});
|
||||
|
||||
if (isRecord(normalized) && Array.isArray(normalized.content)) {
|
||||
return normalized.content.filter((item): item is Record<string, unknown> => isRecord(item));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
Vendored
+25
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export function normalizeTokenRouteMode(routeMode) {
|
||||
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Vendored
+7
@@ -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;
|
||||
@@ -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('(?<!') || body.includes('(?<')) {
|
||||
return false;
|
||||
}
|
||||
if (/(^|[^\\])\\[1-9]/.test(body)) {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+29
-44
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+14
-33
@@ -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() {
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
|
||||
) : 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 ? (
|
||||
<BrandGlyph
|
||||
icon={explicitBrandIcon}
|
||||
alt={routeTitle(route)}
|
||||
alt={resolveRouteTitle(route)}
|
||||
size={18}
|
||||
fallbackText={routeTitle(route)}
|
||||
fallbackText={resolveRouteTitle(route)}
|
||||
/>
|
||||
) : textIcon ? (
|
||||
textIcon
|
||||
@@ -1996,7 +1977,7 @@ export default function Settings() {
|
||||
)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{routeTitle(route)}
|
||||
{resolveRouteTitle(route)}
|
||||
</span>
|
||||
{!route.enabled && (
|
||||
<span
|
||||
|
||||
@@ -7,6 +7,11 @@ import MobileFilterSheet from '../components/MobileFilterSheet.js';
|
||||
import ResponsiveFormGrid from '../components/ResponsiveFormGrid.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
import {
|
||||
isTruthyFlag,
|
||||
parsePositiveInt,
|
||||
resolveAccountCredentialMode,
|
||||
} from './helpers/accountConnection.js';
|
||||
import ModernSelect from '../components/ModernSelect.js';
|
||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||
import { useIsMobile } from '../components/useIsMobile.js';
|
||||
@@ -39,18 +44,6 @@ type AccountTokenSyncResult = {
|
||||
};
|
||||
};
|
||||
|
||||
const 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';
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<RouteListVisibilityItem, 'routeMode'>): boolean {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
};
|
||||
|
||||
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<RouteRow | RouteSummaryRow, 'routeMode'>): boolean {
|
||||
@@ -62,81 +66,11 @@ export function isRouteExactModel(route: Pick<RouteRow | RouteSummaryRow, 'model
|
||||
}
|
||||
|
||||
export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
|
||||
if (!isRegexModelPattern(modelPattern)) return { regex: null, error: null };
|
||||
const body = modelPattern.trim().slice(3).trim();
|
||||
if (!body) return { regex: null, error: 're: 后缺少正则表达式' };
|
||||
try {
|
||||
return { regex: new RegExp(body), error: null };
|
||||
} catch (error) {
|
||||
return { regex: null, error: (error as Error)?.message || '无效正则' };
|
||||
}
|
||||
return parseTokenRouteRegexPattern(modelPattern);
|
||||
}
|
||||
|
||||
function globToRegexSource(glob: string): string {
|
||||
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<string, RegExp | null>();
|
||||
|
||||
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<string, boolean>();
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user