forked from boos/BoosAPI
Merge pull request #244 from cita-777/codex/single-source-consolidation
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`
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Single Source Consolidation Full-Scope Follow-Through
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This document supersedes the scoped execution boundary of
|
||||||
|
[`2026-03-22-single-source-consolidation-pr1.md`](./2026-03-22-single-source-consolidation-pr1.md)
|
||||||
|
for PR #244. The PR1 document remains a historical snapshot of the initial
|
||||||
|
landing slice. This document captures the expanded branch-wide finish that
|
||||||
|
continued until the single-source, workflow, schema, and web admin duplication
|
||||||
|
themes were fully closed for this PR.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Eliminate active double-source implementations for proxy protocols and runtime headers.
|
||||||
|
- Centralize account, OAuth, route refresh, and platform discovery workflows.
|
||||||
|
- Collapse schema metadata normalization and feature compatibility drift points.
|
||||||
|
- Remove repeated web admin page scaffolding and large inline modal/panel implementations.
|
||||||
|
- Finish the branch in a merge-ready state with focused regressions and formal builds.
|
||||||
|
|
||||||
|
## Scope Delivered In This Branch
|
||||||
|
|
||||||
|
### Server and Shared Runtime
|
||||||
|
|
||||||
|
- OpenAI Responses normalization and conversion now flow through one shared implementation boundary.
|
||||||
|
- Shared proxy surface orchestration owns retry recovery, success bookkeeping, and surface-level failure handling.
|
||||||
|
- Provider header/body runtime shaping is centralized behind provider profiles and shared header utilities.
|
||||||
|
- OAuth identity now treats structured columns as the canonical source, with compatibility/backfill isolated to a removable layer.
|
||||||
|
- Route refresh and rebuild sequencing flows through `routeRefreshWorkflow`, not controller-local orchestration.
|
||||||
|
- Platform discovery moved out of `modelService` into dedicated registry-backed discovery modules.
|
||||||
|
- Schema metadata normalization is shared by contract and introspection code paths.
|
||||||
|
- Legacy schema compatibility derives feature-owned allowances from feature specs instead of maintaining a second whitelist.
|
||||||
|
|
||||||
|
### Web Admin
|
||||||
|
|
||||||
|
- Token-route, brand, and downstream client display rules now rely on shared helpers and registries instead of page-local duplicates.
|
||||||
|
- `ResponsiveBatchActionBar` centralizes repeated batch action shells across admin list pages.
|
||||||
|
- `ResponsiveFilterPanel` centralizes the responsive "desktop controls vs mobile sheet" scaffold across admin pages.
|
||||||
|
- `Settings`, `Accounts`, `ModelTester`, and `DownstreamKeys` no longer keep their heaviest modal or drawer UIs inline in the page file.
|
||||||
|
- Brand matching metadata is owned by `brandRegistry`, with `BrandIcon` reduced to rendering concerns.
|
||||||
|
|
||||||
|
## Commit Groups
|
||||||
|
|
||||||
|
### Protocol and Workflow Consolidation
|
||||||
|
|
||||||
|
- review-driven cleanup commits
|
||||||
|
- OAuth identity and route refresh workflow commits
|
||||||
|
- downstream client/runtime header/profile consolidation commits
|
||||||
|
- platform discovery extraction commits
|
||||||
|
|
||||||
|
### Schema and Shared Contract Consolidation
|
||||||
|
|
||||||
|
- shared schema metadata normalization
|
||||||
|
- legacy schema compatibility derivation from feature specs
|
||||||
|
- shared token-route contract and pattern ownership
|
||||||
|
|
||||||
|
### Web Admin Decomposition
|
||||||
|
|
||||||
|
- responsive batch action scaffold
|
||||||
|
- responsive filter scaffold
|
||||||
|
- settings modal extraction
|
||||||
|
- accounts model management modal extraction
|
||||||
|
- model tester panel extraction
|
||||||
|
- downstream key drawer extraction
|
||||||
|
|
||||||
|
## Final Verification Matrix
|
||||||
|
|
||||||
|
Run these groups before calling the branch complete.
|
||||||
|
|
||||||
|
### Protocol and Surface
|
||||||
|
|
||||||
|
- `npm test -- src/server/proxy-core/surfaces/sharedSurface.test.ts src/server/routes/proxy src/server/transformers/openai/responses src/server/transformers/anthropic/messages src/server/services/proxyInputFileResolver.test.ts`
|
||||||
|
|
||||||
|
### OAuth, Workflow, and Platforms
|
||||||
|
|
||||||
|
- `npm test -- src/server/routes/api 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`
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
- `npm run test:schema:unit`
|
||||||
|
- `npm run schema:contract`
|
||||||
|
- `npm run smoke:db:sqlite`
|
||||||
|
|
||||||
|
### Web and Build
|
||||||
|
|
||||||
|
- `npm test -- src/web/pages/responsiveFilterPanel.architecture.test.ts src/web/pages/checkin.mobile.test.tsx src/web/pages/logs.mobile.test.tsx src/web/pages/programLogs.mobile-layout.test.tsx src/web/pages/accounts.batch-actions.test.tsx src/web/pages/sites.mobile-actions.test.tsx src/web/pages/tokens.batch-actions.test.tsx src/web/pages/DownstreamKeys.test.tsx src/web/pages/DownstreamKeys.mobile.test.tsx src/web/pages/downstreamKeys.mobile-layout.test.tsx src/web/pages/tokenRoutes.mobile-layout.test.tsx src/web/pages/tokenRoutes.mobile.test.tsx src/web/pages/models.mobile-layout.test.tsx`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
## Done Definition
|
||||||
|
|
||||||
|
The branch is complete when:
|
||||||
|
|
||||||
|
- no page under `src/web/pages` imports `MobileFilterSheet` directly;
|
||||||
|
- no active protocol path keeps parallel single-source conversions alive;
|
||||||
|
- controller layers do not own route rebuild choreography;
|
||||||
|
- schema metadata and compatibility rules do not keep a second handwritten truth source;
|
||||||
|
- focused regressions and formal builds pass from the commands above.
|
||||||
@@ -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 { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
@@ -16,15 +16,23 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('copyRuntimeDbGeneratedAssets', () => {
|
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-'));
|
const repoRoot = mkdtempSync(join(tmpdir(), 'metapi-runtime-db-assets-'));
|
||||||
tempDirs.push(repoRoot);
|
tempDirs.push(repoRoot);
|
||||||
|
|
||||||
const sourceDir = join(repoRoot, 'src', 'server', 'db', 'generated');
|
const sourceDir = join(repoRoot, 'src', 'server', 'db', 'generated');
|
||||||
const nestedDir = join(sourceDir, 'fixtures');
|
const nestedDir = join(sourceDir, 'fixtures');
|
||||||
|
const sharedDir = join(repoRoot, 'src', 'shared');
|
||||||
|
const staleSharedDistDir = join(repoRoot, 'dist', 'shared');
|
||||||
mkdirSync(nestedDir, { recursive: true });
|
mkdirSync(nestedDir, { recursive: true });
|
||||||
|
mkdirSync(sharedDir, { recursive: true });
|
||||||
|
mkdirSync(staleSharedDistDir, { recursive: true });
|
||||||
writeFileSync(join(sourceDir, 'mysql.bootstrap.sql'), 'create table demo ();');
|
writeFileSync(join(sourceDir, 'mysql.bootstrap.sql'), 'create table demo ();');
|
||||||
writeFileSync(join(nestedDir, 'baseline.json'), '{"ok":true}');
|
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);
|
copyRuntimeDbGeneratedAssets(repoRoot);
|
||||||
|
|
||||||
@@ -34,5 +42,14 @@ describe('copyRuntimeDbGeneratedAssets', () => {
|
|||||||
expect(
|
expect(
|
||||||
readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'fixtures', 'baseline.json'), 'utf8'),
|
readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'fixtures', 'baseline.json'), 'utf8'),
|
||||||
).toBe('{"ok":true}');
|
).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 { cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { basename, dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
function resolveRepoRoot(): string {
|
function resolveRepoRoot(): string {
|
||||||
return resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
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 {
|
export function copyRuntimeDbGeneratedAssets(repoRoot: string = resolveRepoRoot()): void {
|
||||||
const sourceDir = resolve(repoRoot, 'src/server/db/generated');
|
const sourceDir = resolve(repoRoot, 'src/server/db/generated');
|
||||||
const targetDir = resolve(repoRoot, 'dist/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)) {
|
if (!existsSync(sourceDir)) {
|
||||||
throw new Error(`Runtime DB generated assets directory does not exist: ${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 });
|
mkdirSync(dirname(targetDir), { recursive: true });
|
||||||
cpSync(sourceDir, targetDir, { recursive: true, force: 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)) {
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
|||||||
+24
-11
@@ -52,6 +52,22 @@ function normalizeFirstScalar(value: unknown): number | string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectScalar(db: any, query: ReturnType<typeof sql>): Promise<number | string | null> {
|
||||||
|
if (typeof db?.get === 'function') {
|
||||||
|
return normalizeFirstScalar(await db.get(query));
|
||||||
|
}
|
||||||
|
if (typeof db?.all === 'function') {
|
||||||
|
return normalizeFirstScalar(await db.all(query));
|
||||||
|
}
|
||||||
|
if (typeof db?.values === 'function') {
|
||||||
|
return normalizeFirstScalar(await db.values(query));
|
||||||
|
}
|
||||||
|
if (typeof db?.execute === 'function') {
|
||||||
|
return normalizeFirstScalar(await db.execute(query));
|
||||||
|
}
|
||||||
|
throw new Error('database client does not expose a scalar query method');
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const options = parseArgs(process.argv.slice(2));
|
const options = parseArgs(process.argv.slice(2));
|
||||||
if (options.dbType) process.env.DB_TYPE = options.dbType;
|
if (options.dbType) process.env.DB_TYPE = options.dbType;
|
||||||
@@ -75,26 +91,23 @@ async function main() {
|
|||||||
console.log('[db-smoke] dbUrl=(empty, using default sqlite path)');
|
console.log('[db-smoke] dbUrl=(empty, using default sqlite path)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pingRows = await db.execute(sql`select 1 as ok`);
|
const pingScalar = await selectScalar(db, sql`select 1 as ok`);
|
||||||
const pingScalar = normalizeFirstScalar(pingRows);
|
|
||||||
if (Number(pingScalar) !== 1) {
|
if (Number(pingScalar) !== 1) {
|
||||||
throw new Error(`unexpected ping result: ${JSON.stringify(pingRows)}`);
|
throw new Error(`unexpected ping result: ${JSON.stringify(pingScalar)}`);
|
||||||
}
|
}
|
||||||
console.log('[db-smoke] ping ok');
|
console.log('[db-smoke] ping ok');
|
||||||
|
|
||||||
const txRows = await db.transaction(async (tx: any) => tx.execute(sql`select 1 as ok`));
|
const txScalar = await db.transaction(async (tx: any) => selectScalar(tx, sql`select 1 as ok`));
|
||||||
const txScalar = normalizeFirstScalar(txRows);
|
|
||||||
if (Number(txScalar) !== 1) {
|
if (Number(txScalar) !== 1) {
|
||||||
throw new Error(`unexpected transaction ping result: ${JSON.stringify(txRows)}`);
|
throw new Error(`unexpected transaction ping result: ${JSON.stringify(txScalar)}`);
|
||||||
}
|
}
|
||||||
console.log('[db-smoke] transaction ok');
|
console.log('[db-smoke] transaction ok');
|
||||||
|
|
||||||
const versionRows = dbType === 'sqlite'
|
const version = dbType === 'sqlite'
|
||||||
? await db.execute(sql`select sqlite_version() as v`)
|
? await selectScalar(db, sql`select sqlite_version() as v`)
|
||||||
: await db.execute(sql`select version() as v`);
|
: await selectScalar(db, sql`select version() as v`);
|
||||||
const version = normalizeFirstScalar(versionRows);
|
|
||||||
if (typeof version !== 'string' || version.trim().length === 0) {
|
if (typeof version !== 'string' || version.trim().length === 0) {
|
||||||
throw new Error(`failed to read server version: ${JSON.stringify(versionRows)}`);
|
throw new Error(`failed to read server version: ${JSON.stringify(version)}`);
|
||||||
}
|
}
|
||||||
console.log(`[db-smoke] version=${version.slice(0, 120)}`);
|
console.log(`[db-smoke] version=${version.slice(0, 120)}`);
|
||||||
|
|
||||||
|
|||||||
@@ -85,4 +85,25 @@ describe('buildConfig', () => {
|
|||||||
expect(response.json()).toEqual({ textLength: largeText.length });
|
expect(response.json()).toEqual({ textLength: largeText.length });
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('trusts forwarded client IP headers for reverse-proxy deployments', async () => {
|
||||||
|
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
||||||
|
|
||||||
|
app.get('/ip', async (request) => ({
|
||||||
|
ip: request.ip,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/ip',
|
||||||
|
remoteAddress: '10.0.0.8',
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '203.0.113.5, 10.0.0.8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ ip: '203.0.113.5' });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export function buildFastifyOptions(
|
|||||||
): FastifyServerOptions {
|
): FastifyServerOptions {
|
||||||
return {
|
return {
|
||||||
logger: true,
|
logger: true,
|
||||||
|
trustProxy: true,
|
||||||
bodyLimit: appConfig.requestBodyLimit,
|
bodyLimit: appConfig.requestBodyLimit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ export interface AccountTokenSchemaInspector {
|
|||||||
execute(sqlText: string): Promise<void>;
|
execute(sqlText: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccountTokenColumnCompatibilitySpec = {
|
export type AccountTokenColumnCompatibilitySpec = {
|
||||||
table: 'account_tokens';
|
table: 'account_tokens';
|
||||||
column: string;
|
column: string;
|
||||||
addSql: Record<AccountTokenSchemaDialect, string>;
|
addSql: Record<AccountTokenSchemaDialect, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [
|
export const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilitySpec[] = [
|
||||||
{
|
{
|
||||||
table: 'account_tokens',
|
table: 'account_tokens',
|
||||||
column: 'token_group',
|
column: 'token_group',
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('legacySchemaCompat architecture boundaries', () => {
|
||||||
|
it('derives feature-owned legacy mutations from compatibility specs instead of hardcoding a second full whitelist', () => {
|
||||||
|
const source = readSource('./legacySchemaCompat.ts');
|
||||||
|
|
||||||
|
expect(source).toContain('ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS');
|
||||||
|
expect(source).toContain('PROXY_FILE_COLUMN_COMPATIBILITY_SPECS');
|
||||||
|
expect(source).toContain('ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS');
|
||||||
|
expect(source).toContain('SITE_COLUMN_COMPATIBILITY_SPECS');
|
||||||
|
expect(source).toContain('SITE_TABLE_COMPATIBILITY_SPECS');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
|
ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS,
|
||||||
ensureAccountTokenSchemaCompatibility,
|
ensureAccountTokenSchemaCompatibility,
|
||||||
type AccountTokenSchemaInspector,
|
type AccountTokenSchemaInspector,
|
||||||
} from './accountTokenSchemaCompatibility.js';
|
} from './accountTokenSchemaCompatibility.js';
|
||||||
import {
|
import {
|
||||||
ensureProxyFileSchemaCompatibility,
|
ensureProxyFileSchemaCompatibility,
|
||||||
|
PROXY_FILE_COLUMN_COMPATIBILITY_SPECS,
|
||||||
|
PROXY_FILE_INDEX_COMPATIBILITY_SPECS,
|
||||||
|
PROXY_FILE_TABLE_COMPATIBILITY_SPECS,
|
||||||
type ProxyFileSchemaInspector,
|
type ProxyFileSchemaInspector,
|
||||||
} from './proxyFileSchemaCompatibility.js';
|
} from './proxyFileSchemaCompatibility.js';
|
||||||
import {
|
import {
|
||||||
ensureRouteGroupingSchemaCompatibility,
|
ensureRouteGroupingSchemaCompatibility,
|
||||||
|
ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS,
|
||||||
|
ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS,
|
||||||
type RouteGroupingSchemaInspector,
|
type RouteGroupingSchemaInspector,
|
||||||
} from './routeGroupingSchemaCompatibility.js';
|
} from './routeGroupingSchemaCompatibility.js';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +23,8 @@ import {
|
|||||||
} from './sharedIndexSchemaCompatibility.js';
|
} from './sharedIndexSchemaCompatibility.js';
|
||||||
import {
|
import {
|
||||||
ensureSiteSchemaCompatibility,
|
ensureSiteSchemaCompatibility,
|
||||||
|
SITE_COLUMN_COMPATIBILITY_SPECS,
|
||||||
|
SITE_TABLE_COMPATIBILITY_SPECS,
|
||||||
type SiteSchemaInspector,
|
type SiteSchemaInspector,
|
||||||
} from './siteSchemaCompatibility.js';
|
} from './siteSchemaCompatibility.js';
|
||||||
|
|
||||||
@@ -29,36 +37,16 @@ export interface LegacySchemaCompatInspector extends
|
|||||||
AccountTokenSchemaInspector,
|
AccountTokenSchemaInspector,
|
||||||
SharedIndexSchemaInspector {}
|
SharedIndexSchemaInspector {}
|
||||||
|
|
||||||
const LEGACY_COMPAT_TABLES = new Set([
|
const BOOTSTRAP_OWNED_LEGACY_TABLES = [
|
||||||
'account_tokens',
|
'account_tokens',
|
||||||
'token_model_availability',
|
'token_model_availability',
|
||||||
'route_group_sources',
|
|
||||||
'proxy_video_tasks',
|
'proxy_video_tasks',
|
||||||
'proxy_files',
|
|
||||||
'downstream_api_keys',
|
'downstream_api_keys',
|
||||||
'site_disabled_models',
|
];
|
||||||
]);
|
|
||||||
|
|
||||||
const LEGACY_COMPAT_COLUMNS = new Set([
|
const BOOTSTRAP_OWNED_LEGACY_COLUMNS = [
|
||||||
'sites.status',
|
'sites.status',
|
||||||
'sites.proxy_url',
|
|
||||||
'sites.use_system_proxy',
|
|
||||||
'sites.custom_headers',
|
|
||||||
'sites.external_checkin_url',
|
|
||||||
'sites.global_weight',
|
|
||||||
'account_tokens.token_group',
|
|
||||||
'account_tokens.value_status',
|
|
||||||
'token_routes.display_name',
|
|
||||||
'token_routes.display_icon',
|
|
||||||
'token_routes.route_mode',
|
|
||||||
'token_routes.decision_snapshot',
|
|
||||||
'token_routes.decision_refreshed_at',
|
|
||||||
'token_routes.routing_strategy',
|
|
||||||
'route_channels.token_id',
|
'route_channels.token_id',
|
||||||
'route_channels.source_model',
|
|
||||||
'route_channels.last_selected_at',
|
|
||||||
'route_channels.consecutive_fail_count',
|
|
||||||
'route_channels.cooldown_level',
|
|
||||||
'proxy_video_tasks.status_snapshot',
|
'proxy_video_tasks.status_snapshot',
|
||||||
'proxy_video_tasks.upstream_response_meta',
|
'proxy_video_tasks.upstream_response_meta',
|
||||||
'proxy_video_tasks.last_upstream_status',
|
'proxy_video_tasks.last_upstream_status',
|
||||||
@@ -71,16 +59,12 @@ const LEGACY_COMPAT_COLUMNS = new Set([
|
|||||||
'proxy_logs.client_app_name',
|
'proxy_logs.client_app_name',
|
||||||
'proxy_logs.client_confidence',
|
'proxy_logs.client_confidence',
|
||||||
'proxy_logs.downstream_api_key_id',
|
'proxy_logs.downstream_api_key_id',
|
||||||
]);
|
];
|
||||||
|
|
||||||
const LEGACY_COMPAT_INDEXES = new Set([
|
const BOOTSTRAP_OWNED_LEGACY_INDEXES = [
|
||||||
'site_disabled_models_site_model_unique',
|
|
||||||
'site_disabled_models_site_id_idx',
|
|
||||||
'token_model_availability_token_model_unique',
|
'token_model_availability_token_model_unique',
|
||||||
'proxy_video_tasks_public_id_unique',
|
'proxy_video_tasks_public_id_unique',
|
||||||
'proxy_video_tasks_upstream_video_id_idx',
|
'proxy_video_tasks_upstream_video_id_idx',
|
||||||
'proxy_files_public_id_unique',
|
|
||||||
'proxy_files_owner_lookup_idx',
|
|
||||||
'downstream_api_keys_key_unique',
|
'downstream_api_keys_key_unique',
|
||||||
'downstream_api_keys_name_idx',
|
'downstream_api_keys_name_idx',
|
||||||
'downstream_api_keys_enabled_idx',
|
'downstream_api_keys_enabled_idx',
|
||||||
@@ -88,23 +72,53 @@ const LEGACY_COMPAT_INDEXES = new Set([
|
|||||||
'proxy_logs_client_app_id_created_at_idx',
|
'proxy_logs_client_app_id_created_at_idx',
|
||||||
'proxy_logs_client_family_created_at_idx',
|
'proxy_logs_client_family_created_at_idx',
|
||||||
'proxy_logs_downstream_api_key_created_at_idx',
|
'proxy_logs_downstream_api_key_created_at_idx',
|
||||||
'route_group_sources_group_source_unique',
|
];
|
||||||
'route_group_sources_source_route_id_idx',
|
|
||||||
...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
|
|
||||||
]);
|
|
||||||
|
|
||||||
function normalizeSqlText(sqlText: string): string {
|
function normalizeSqlText(sqlText: string): string {
|
||||||
return sqlText.trim().replace(/\s+/g, ' ').toLowerCase();
|
return sqlText.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEGACY_COMPAT_UPDATES = new Set([
|
function extractIndexName(sqlText: string): string | null {
|
||||||
'UPDATE sites SET use_system_proxy = 0 WHERE use_system_proxy IS NULL;',
|
const match = normalizeSqlText(sqlText).match(
|
||||||
'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
|
/^create (?:unique )?index(?: if not exists)? [`"]?([a-z0-9_]+)[`"]?/i,
|
||||||
'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
);
|
||||||
'UPDATE sites SET global_weight = 1 WHERE global_weight IS NULL OR global_weight <= 0;',
|
return match?.[1] ?? null;
|
||||||
'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
|
}
|
||||||
'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
|
|
||||||
].map((sqlText) => normalizeSqlText(sqlText)));
|
const LEGACY_COMPAT_TABLES = new Set([
|
||||||
|
...SITE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||||
|
...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||||
|
...PROXY_FILE_TABLE_COMPATIBILITY_SPECS.map((spec) => spec.table),
|
||||||
|
...BOOTSTRAP_OWNED_LEGACY_TABLES,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LEGACY_COMPAT_COLUMNS = new Set([
|
||||||
|
...SITE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `sites.${spec.column}`),
|
||||||
|
...ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||||
|
...ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||||
|
...PROXY_FILE_COLUMN_COMPATIBILITY_SPECS.map((spec) => `${spec.table}.${spec.column}`),
|
||||||
|
...BOOTSTRAP_OWNED_LEGACY_COLUMNS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LEGACY_COMPAT_INDEXES = new Set([
|
||||||
|
...SITE_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => spec.postCreateSql ? Object.values(spec.postCreateSql) : [])
|
||||||
|
.flat()
|
||||||
|
.map((sqlText) => extractIndexName(sqlText))
|
||||||
|
.filter((indexName): indexName is string => Boolean(indexName)),
|
||||||
|
...ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS.flatMap((spec) => Object.values(spec.createSql))
|
||||||
|
.flat()
|
||||||
|
.map((sqlText) => extractIndexName(sqlText))
|
||||||
|
.filter((indexName): indexName is string => Boolean(indexName)),
|
||||||
|
...PROXY_FILE_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
|
||||||
|
...BOOTSTRAP_OWNED_LEGACY_INDEXES,
|
||||||
|
...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LEGACY_COMPAT_UPDATES = new Set(
|
||||||
|
SITE_COLUMN_COMPATIBILITY_SPECS
|
||||||
|
.flatMap((spec) => spec.normalizeSql ? Object.values(spec.normalizeSql) : [])
|
||||||
|
.map((sqlText) => normalizeSqlText(sqlText)),
|
||||||
|
);
|
||||||
|
|
||||||
export function classifyLegacyCompatMutation(sqlText: string): LegacySchemaCompatClassification {
|
export function classifyLegacyCompatMutation(sqlText: string): LegacySchemaCompatClassification {
|
||||||
const normalized = normalizeSqlText(sqlText);
|
const normalized = normalizeSqlText(sqlText);
|
||||||
|
|||||||
@@ -62,6 +62,19 @@ describe('ensureProxyFileSchemaCompatibility', () => {
|
|||||||
expect(executedSql.some((sqlText) => ownerIndexPattern.test(sqlText))).toBe(true);
|
expect(executedSql.some((sqlText) => ownerIndexPattern.test(sqlText))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits MySQL indexes compatible with generated bootstrap SQL', async () => {
|
||||||
|
const { inspector, executedSql } = createInspector('mysql');
|
||||||
|
|
||||||
|
await ensureProxyFileSchemaCompatibility(inspector);
|
||||||
|
|
||||||
|
expect(executedSql).toContain(
|
||||||
|
'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
|
||||||
|
);
|
||||||
|
expect(executedSql).toContain(
|
||||||
|
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('adds missing columns on existing table before ensuring indexes', async () => {
|
it('adds missing columns on existing table before ensuring indexes', async () => {
|
||||||
const { inspector, executedSql } = createInspector('postgres', {
|
const { inspector, executedSql } = createInspector('postgres', {
|
||||||
hasTable: true,
|
hasTable: true,
|
||||||
|
|||||||
@@ -8,18 +8,30 @@ export interface ProxyFileSchemaInspector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProxyFileColumnCompatibilitySpec = {
|
type ProxyFileColumnCompatibilitySpec = {
|
||||||
|
table: 'proxy_files';
|
||||||
column: string;
|
column: string;
|
||||||
addSql: Record<ProxyFileSchemaDialect, string>;
|
addSql: Record<ProxyFileSchemaDialect, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CREATE_TABLE_SQL: Record<ProxyFileSchemaDialect, string> = {
|
export type ProxyFileTableCompatibilitySpec = {
|
||||||
|
table: 'proxy_files';
|
||||||
|
createSql: Record<ProxyFileSchemaDialect, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROXY_FILE_TABLE_COMPATIBILITY_SPECS: ProxyFileTableCompatibilitySpec[] = [
|
||||||
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
|
createSql: {
|
||||||
sqlite: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)',
|
sqlite: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)',
|
||||||
postgres: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" SERIAL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)',
|
postgres: 'CREATE TABLE IF NOT EXISTS "proxy_files" ("id" SERIAL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)',
|
||||||
mysql: 'CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, `public_id` VARCHAR(191) NOT NULL, `owner_type` VARCHAR(64) NOT NULL, `owner_id` VARCHAR(191) NOT NULL, `filename` TEXT NOT NULL, `mime_type` VARCHAR(191) NOT NULL, `purpose` TEXT NULL, `byte_size` INTEGER NOT NULL, `sha256` VARCHAR(191) NOT NULL, `content_base64` LONGTEXT NOT NULL, `created_at` TEXT NULL, `updated_at` TEXT NULL, `deleted_at` TEXT NULL)',
|
mysql: 'CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, `public_id` VARCHAR(191) NOT NULL, `owner_type` VARCHAR(64) NOT NULL, `owner_id` VARCHAR(191) NOT NULL, `filename` TEXT NOT NULL, `mime_type` VARCHAR(191) NOT NULL, `purpose` TEXT NULL, `byte_size` INTEGER NOT NULL, `sha256` VARCHAR(191) NOT NULL, `content_base64` LONGTEXT NOT NULL, `created_at` TEXT NULL, `updated_at` TEXT NULL, `deleted_at` TEXT NULL)',
|
||||||
};
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
export const PROXY_FILE_COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'public_id',
|
column: 'public_id',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "public_id" TEXT',
|
||||||
@@ -28,6 +40,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'owner_type',
|
column: 'owner_type',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_type" TEXT',
|
||||||
@@ -36,6 +49,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'owner_id',
|
column: 'owner_id',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "owner_id" TEXT',
|
||||||
@@ -44,6 +58,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'filename',
|
column: 'filename',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "filename" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "filename" TEXT',
|
||||||
@@ -52,6 +67,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'mime_type',
|
column: 'mime_type',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "mime_type" TEXT',
|
||||||
@@ -60,6 +76,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'purpose',
|
column: 'purpose',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "purpose" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "purpose" TEXT',
|
||||||
@@ -68,6 +85,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'byte_size',
|
column: 'byte_size',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "byte_size" INTEGER',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "byte_size" INTEGER',
|
||||||
@@ -76,6 +94,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'sha256',
|
column: 'sha256',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "sha256" TEXT',
|
||||||
@@ -84,6 +103,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'content_base64',
|
column: 'content_base64',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "content_base64" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "content_base64" TEXT',
|
||||||
@@ -92,6 +112,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'created_at',
|
column: 'created_at',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "created_at" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "created_at" TEXT',
|
||||||
@@ -100,6 +121,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'updated_at',
|
column: 'updated_at',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "updated_at" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "updated_at" TEXT',
|
||||||
@@ -108,6 +130,7 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
column: 'deleted_at',
|
column: 'deleted_at',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "deleted_at" TEXT',
|
sqlite: 'ALTER TABLE "proxy_files" ADD COLUMN "deleted_at" TEXT',
|
||||||
@@ -117,21 +140,33 @@ const COLUMN_COMPATIBILITY_SPECS: ProxyFileColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const CREATE_INDEX_SQL: Record<ProxyFileSchemaDialect, string[]> = {
|
export type ProxyFileIndexCompatibilitySpec = {
|
||||||
sqlite: [
|
table: 'proxy_files';
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")',
|
indexName: string;
|
||||||
'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")',
|
createSql: Record<ProxyFileSchemaDialect, string>;
|
||||||
],
|
|
||||||
postgres: [
|
|
||||||
'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")',
|
|
||||||
'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")',
|
|
||||||
],
|
|
||||||
mysql: [
|
|
||||||
'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`)',
|
|
||||||
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`, `owner_id`)',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PROXY_FILE_INDEX_COMPATIBILITY_SPECS: ProxyFileIndexCompatibilitySpec[] = [
|
||||||
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
|
indexName: 'proxy_files_public_id_unique',
|
||||||
|
createSql: {
|
||||||
|
sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")',
|
||||||
|
postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "proxy_files_public_id_unique" ON "proxy_files" ("public_id")',
|
||||||
|
mysql: 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
table: 'proxy_files',
|
||||||
|
indexName: 'proxy_files_owner_lookup_idx',
|
||||||
|
createSql: {
|
||||||
|
sqlite: 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")',
|
||||||
|
postgres: 'CREATE INDEX IF NOT EXISTS "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at")',
|
||||||
|
mysql: 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function normalizeSchemaErrorMessage(error: unknown): string {
|
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||||
if (typeof error === 'object' && error && 'message' in error) {
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
return String((error as { message?: unknown }).message || '');
|
return String((error as { message?: unknown }).message || '');
|
||||||
@@ -155,16 +190,17 @@ async function executeIgnoreDuplicate(inspector: ProxyFileSchemaInspector, sqlTe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureProxyFileSchemaCompatibility(inspector: ProxyFileSchemaInspector): Promise<void> {
|
export async function ensureProxyFileSchemaCompatibility(inspector: ProxyFileSchemaInspector): Promise<void> {
|
||||||
if (!await inspector.tableExists('proxy_files')) {
|
const [tableSpec] = PROXY_FILE_TABLE_COMPATIBILITY_SPECS;
|
||||||
await executeIgnoreDuplicate(inspector, CREATE_TABLE_SQL[inspector.dialect]);
|
if (!await inspector.tableExists(tableSpec.table)) {
|
||||||
|
await executeIgnoreDuplicate(inspector, tableSpec.createSql[inspector.dialect]);
|
||||||
} else {
|
} else {
|
||||||
for (const spec of COLUMN_COMPATIBILITY_SPECS) {
|
for (const spec of PROXY_FILE_COLUMN_COMPATIBILITY_SPECS) {
|
||||||
if (await inspector.columnExists('proxy_files', spec.column)) continue;
|
if (await inspector.columnExists(spec.table, spec.column)) continue;
|
||||||
await executeIgnoreDuplicate(inspector, spec.addSql[inspector.dialect]);
|
await executeIgnoreDuplicate(inspector, spec.addSql[inspector.dialect]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const sqlText of CREATE_INDEX_SQL[inspector.dialect]) {
|
for (const spec of PROXY_FILE_INDEX_COMPATIBILITY_SPECS) {
|
||||||
await executeIgnoreDuplicate(inspector, sqlText);
|
await executeIgnoreDuplicate(inspector, spec.createSql[inspector.dialect]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ export interface RouteGroupingSchemaInspector {
|
|||||||
execute(sqlText: string): Promise<void>;
|
execute(sqlText: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteGroupingColumnCompatibilitySpec = {
|
export type RouteGroupingColumnCompatibilitySpec = {
|
||||||
table: 'token_routes' | 'route_channels';
|
table: 'token_routes' | 'route_channels';
|
||||||
column: string;
|
column: string;
|
||||||
addSql: Record<RouteGroupingSchemaDialect, string>;
|
addSql: Record<RouteGroupingSchemaDialect, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RouteGroupingTableCompatibilitySpec = {
|
export type RouteGroupingTableCompatibilitySpec = {
|
||||||
table: 'route_group_sources';
|
table: 'route_group_sources';
|
||||||
createSql: Record<RouteGroupingSchemaDialect, string[]>;
|
createSql: Record<RouteGroupingSchemaDialect, string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [
|
export const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [
|
||||||
{
|
{
|
||||||
table: 'token_routes',
|
table: 'token_routes',
|
||||||
column: 'display_name',
|
column: 'display_name',
|
||||||
@@ -111,7 +111,7 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [
|
export const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [
|
||||||
{
|
{
|
||||||
table: 'route_group_sources',
|
table: 'route_group_sources',
|
||||||
createSql: {
|
createSql: {
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
normalizeLogicalColumnType,
|
||||||
|
normalizeSchemaMetadataDefaultValue,
|
||||||
|
type LogicalColumnType,
|
||||||
|
} from './schemaMetadata.js';
|
||||||
|
|
||||||
export type LogicalColumnType =
|
export type { LogicalColumnType } from './schemaMetadata.js';
|
||||||
| 'integer'
|
|
||||||
| 'real'
|
|
||||||
| 'text'
|
|
||||||
| 'boolean'
|
|
||||||
| 'datetime'
|
|
||||||
| 'json';
|
|
||||||
|
|
||||||
export interface SchemaContractColumn {
|
export interface SchemaContractColumn {
|
||||||
logicalType: LogicalColumnType;
|
logicalType: LogicalColumnType;
|
||||||
@@ -118,66 +117,16 @@ function applySqliteMigrations(sqlite: Database.Database, migrationsFolder: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDefaultValue(defaultValue: string | null): string | null {
|
function normalizeDefaultValue(defaultValue: string | null): string | null {
|
||||||
if (defaultValue == null) return null;
|
return normalizeSchemaMetadataDefaultValue(defaultValue);
|
||||||
return String(defaultValue).trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
|
|
||||||
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return normalizedColumn.startsWith('is_')
|
|
||||||
|| normalizedColumn.startsWith('use_')
|
|
||||||
|| normalizedColumn.startsWith('has_')
|
|
||||||
|| normalizedColumn.endsWith('_enabled')
|
|
||||||
|| normalizedColumn.endsWith('_available')
|
|
||||||
|| normalizedColumn === 'read'
|
|
||||||
|| normalizedColumn === 'enabled'
|
|
||||||
|| normalizedColumn === 'available'
|
|
||||||
|| normalizedColumn === 'manual_override';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
const normalizedDefault = (defaultValue || '').toLowerCase();
|
|
||||||
return normalizedColumn.endsWith('_at')
|
|
||||||
|| normalizedColumn.endsWith('_until')
|
|
||||||
|| normalizedColumn.endsWith('_refresh')
|
|
||||||
|| normalizedDefault.includes('datetime(')
|
|
||||||
|| normalizedDefault.includes('current_timestamp');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJsonLikeColumn(columnName: string): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
return normalizedColumn.endsWith('_json')
|
|
||||||
|| normalizedColumn.includes('snapshot')
|
|
||||||
|| normalizedColumn.includes('mapping')
|
|
||||||
|| normalizedColumn.includes('headers')
|
|
||||||
|| normalizedColumn.includes('config')
|
|
||||||
|| normalizedColumn.includes('details')
|
|
||||||
|| normalizedColumn.includes('meta')
|
|
||||||
|| normalizedColumn.includes('models')
|
|
||||||
|| normalizedColumn.includes('route_ids')
|
|
||||||
|| normalizedColumn.includes('multipliers');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType {
|
function normalizeLogicalType(columnName: string, declaredType: string, defaultValue: string | null): LogicalColumnType {
|
||||||
const normalizedType = declaredType.trim().toLowerCase();
|
return normalizeLogicalColumnType({
|
||||||
if (normalizedType.includes('int')) {
|
columnName,
|
||||||
return isBooleanLikeColumn(columnName, defaultValue) ? 'boolean' : 'integer';
|
declaredType,
|
||||||
}
|
defaultValue,
|
||||||
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float')) {
|
dialect: 'sqlite',
|
||||||
return 'real';
|
});
|
||||||
}
|
|
||||||
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
|
||||||
if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime';
|
|
||||||
if (isJsonLikeColumn(columnName)) return 'json';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
if (isDateTimeLikeColumn(columnName, defaultValue)) return 'datetime';
|
|
||||||
return 'text';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
function readTables(sqlite: Database.Database): Record<string, SchemaContractTable> {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
resolveGeneratedArtifactPath,
|
resolveGeneratedArtifactPath,
|
||||||
} from './schemaArtifactGenerator.js';
|
} from './schemaArtifactGenerator.js';
|
||||||
import type {
|
import type {
|
||||||
LogicalColumnType,
|
|
||||||
SchemaContract,
|
SchemaContract,
|
||||||
SchemaContractColumn,
|
SchemaContractColumn,
|
||||||
SchemaContractForeignKey,
|
SchemaContractForeignKey,
|
||||||
@@ -20,6 +19,10 @@ import type {
|
|||||||
SchemaContractUnique,
|
SchemaContractUnique,
|
||||||
} from './schemaContract.js';
|
} from './schemaContract.js';
|
||||||
import { resolveMigrationsFolder } from './schemaContract.js';
|
import { resolveMigrationsFolder } from './schemaContract.js';
|
||||||
|
import {
|
||||||
|
normalizeLogicalColumnType,
|
||||||
|
type LogicalColumnType,
|
||||||
|
} from './schemaMetadata.js';
|
||||||
|
|
||||||
export type SchemaIntrospectionDialect = 'sqlite' | 'mysql' | 'postgres';
|
export type SchemaIntrospectionDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
|
|
||||||
@@ -182,122 +185,18 @@ function normalizeDeleteRule(value: string | null | undefined): string | null {
|
|||||||
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
|
|
||||||
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return normalizedColumn.startsWith('is_')
|
|
||||||
|| normalizedColumn.startsWith('use_')
|
|
||||||
|| normalizedColumn.startsWith('has_')
|
|
||||||
|| normalizedColumn.endsWith('_enabled')
|
|
||||||
|| normalizedColumn.endsWith('_available')
|
|
||||||
|| normalizedColumn === 'read'
|
|
||||||
|| normalizedColumn === 'enabled'
|
|
||||||
|| normalizedColumn === 'available'
|
|
||||||
|| normalizedColumn === 'manual_override';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
const normalizedDefault = (defaultValue || '').toLowerCase();
|
|
||||||
return normalizedColumn.endsWith('_at')
|
|
||||||
|| normalizedColumn.endsWith('_until')
|
|
||||||
|| normalizedColumn.endsWith('_refresh')
|
|
||||||
|| normalizedDefault.includes("datetime('now')")
|
|
||||||
|| normalizedDefault.includes('current_timestamp')
|
|
||||||
|| normalizedDefault.includes('now()');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJsonLikeColumn(columnName: string): boolean {
|
|
||||||
const normalizedColumn = columnName.toLowerCase();
|
|
||||||
return normalizedColumn.endsWith('_json')
|
|
||||||
|| normalizedColumn.includes('snapshot')
|
|
||||||
|| normalizedColumn.includes('mapping')
|
|
||||||
|| normalizedColumn.includes('headers')
|
|
||||||
|| normalizedColumn.includes('config')
|
|
||||||
|| normalizedColumn.includes('details')
|
|
||||||
|| normalizedColumn.includes('meta')
|
|
||||||
|| normalizedColumn.includes('models')
|
|
||||||
|| normalizedColumn.includes('route_ids')
|
|
||||||
|| normalizedColumn.includes('multipliers');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeSqlType(
|
export function normalizeSqlType(
|
||||||
dialect: SchemaIntrospectionDialect,
|
dialect: SchemaIntrospectionDialect,
|
||||||
declaredType: string,
|
declaredType: string,
|
||||||
columnName: string,
|
columnName: string,
|
||||||
rawDefaultValue: string | null = null,
|
rawDefaultValue: string | null = null,
|
||||||
): LogicalColumnType {
|
): LogicalColumnType {
|
||||||
const normalizedType = declaredType.trim().toLowerCase();
|
return normalizeLogicalColumnType({
|
||||||
const normalizedDefault = normalizeDefaultValue(rawDefaultValue);
|
dialect,
|
||||||
|
declaredType,
|
||||||
if (dialect === 'mysql') {
|
columnName,
|
||||||
if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) {
|
defaultValue: normalizeDefaultValue(rawDefaultValue),
|
||||||
return 'boolean';
|
});
|
||||||
}
|
|
||||||
if (normalizedType.includes('json')) {
|
|
||||||
return 'json';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
|
||||||
return 'datetime';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('int')) {
|
|
||||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) {
|
|
||||||
return 'real';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
|
||||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
|
||||||
if (isJsonLikeColumn(columnName)) return 'json';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dialect === 'postgres') {
|
|
||||||
if (normalizedType.includes('bool')) {
|
|
||||||
return 'boolean';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('json')) {
|
|
||||||
return 'json';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('timestamp') || normalizedType === 'date') {
|
|
||||||
return 'datetime';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('int')) {
|
|
||||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) {
|
|
||||||
return 'real';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
|
||||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
|
||||||
if (isJsonLikeColumn(columnName)) return 'json';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedType.includes('int')) {
|
|
||||||
return isBooleanLikeColumn(columnName, normalizedDefault) ? 'boolean' : 'integer';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) {
|
|
||||||
return 'real';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('json')) {
|
|
||||||
return 'json';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
|
||||||
return 'datetime';
|
|
||||||
}
|
|
||||||
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
|
||||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
|
||||||
if (isJsonLikeColumn(columnName)) return 'json';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
if (isDateTimeLikeColumn(columnName, normalizedDefault)) return 'datetime';
|
|
||||||
return 'text';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDefaultValueForColumn(
|
function normalizeDefaultValueForColumn(
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('schema metadata architecture boundaries', () => {
|
||||||
|
it('keeps logical column type heuristics in a shared schemaMetadata helper', () => {
|
||||||
|
const contractSource = readSource('./schemaContract.ts');
|
||||||
|
const introspectionSource = readSource('./schemaIntrospection.ts');
|
||||||
|
|
||||||
|
expect(contractSource).toContain("from './schemaMetadata.js'");
|
||||||
|
expect(introspectionSource).toContain("from './schemaMetadata.js'");
|
||||||
|
|
||||||
|
for (const source of [contractSource, introspectionSource]) {
|
||||||
|
expect(source).not.toContain('function isBooleanLikeColumn');
|
||||||
|
expect(source).not.toContain('function isDateTimeLikeColumn');
|
||||||
|
expect(source).not.toContain('function isJsonLikeColumn');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
export type LogicalColumnType =
|
||||||
|
| 'integer'
|
||||||
|
| 'real'
|
||||||
|
| 'text'
|
||||||
|
| 'boolean'
|
||||||
|
| 'datetime'
|
||||||
|
| 'json';
|
||||||
|
|
||||||
|
export type SchemaMetadataDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
|
|
||||||
|
function isBooleanLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
||||||
|
const normalizedColumn = columnName.toLowerCase();
|
||||||
|
const normalizedDefault = (defaultValue || '').trim().toLowerCase();
|
||||||
|
if (normalizedDefault === 'true' || normalizedDefault === 'false') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return normalizedColumn.startsWith('is_')
|
||||||
|
|| normalizedColumn.startsWith('use_')
|
||||||
|
|| normalizedColumn.startsWith('has_')
|
||||||
|
|| normalizedColumn.endsWith('_enabled')
|
||||||
|
|| normalizedColumn.endsWith('_available')
|
||||||
|
|| normalizedColumn === 'read'
|
||||||
|
|| normalizedColumn === 'enabled'
|
||||||
|
|| normalizedColumn === 'available'
|
||||||
|
|| normalizedColumn === 'manual_override';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateTimeLikeColumn(columnName: string, defaultValue: string | null): boolean {
|
||||||
|
const normalizedColumn = columnName.toLowerCase();
|
||||||
|
const normalizedDefault = (defaultValue || '').toLowerCase();
|
||||||
|
return normalizedColumn.endsWith('_at')
|
||||||
|
|| normalizedColumn.endsWith('_until')
|
||||||
|
|| normalizedColumn.endsWith('_refresh')
|
||||||
|
|| normalizedDefault.includes('datetime(')
|
||||||
|
|| normalizedDefault.includes('current_timestamp')
|
||||||
|
|| normalizedDefault.includes('now()');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonLikeColumn(columnName: string): boolean {
|
||||||
|
const normalizedColumn = columnName.toLowerCase();
|
||||||
|
return normalizedColumn.endsWith('_json')
|
||||||
|
|| normalizedColumn.includes('snapshot')
|
||||||
|
|| normalizedColumn.includes('mapping')
|
||||||
|
|| normalizedColumn.includes('headers')
|
||||||
|
|| normalizedColumn.includes('config')
|
||||||
|
|| normalizedColumn.includes('details')
|
||||||
|
|| normalizedColumn.includes('meta')
|
||||||
|
|| normalizedColumn.includes('models')
|
||||||
|
|| normalizedColumn.includes('route_ids')
|
||||||
|
|| normalizedColumn.includes('multipliers');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSchemaMetadataDefaultValue(defaultValue: string | null | undefined): string | null {
|
||||||
|
if (defaultValue == null) return null;
|
||||||
|
return String(defaultValue).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLogicalColumnType(input: {
|
||||||
|
declaredType: string;
|
||||||
|
columnName: string;
|
||||||
|
defaultValue?: string | null;
|
||||||
|
dialect?: SchemaMetadataDialect;
|
||||||
|
}): LogicalColumnType {
|
||||||
|
const normalizedType = input.declaredType.trim().toLowerCase();
|
||||||
|
const normalizedDefault = normalizeSchemaMetadataDefaultValue(input.defaultValue);
|
||||||
|
const dialect = input.dialect;
|
||||||
|
|
||||||
|
if (dialect === 'mysql') {
|
||||||
|
if (normalizedType.includes('tinyint(1)') || normalizedType.includes('boolean') || normalizedType.includes('bool')) {
|
||||||
|
return 'boolean';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('json')) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
||||||
|
return 'datetime';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('int')) {
|
||||||
|
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('real') || normalizedType.includes('decimal')) {
|
||||||
|
return 'real';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
||||||
|
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||||
|
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialect === 'postgres') {
|
||||||
|
if (normalizedType.includes('bool')) {
|
||||||
|
return 'boolean';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('json')) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('timestamp') || normalizedType === 'date') {
|
||||||
|
return 'datetime';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('int')) {
|
||||||
|
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('double') || normalizedType.includes('real') || normalizedType.includes('numeric') || normalizedType.includes('decimal')) {
|
||||||
|
return 'real';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('char') || normalizedType.includes('text')) {
|
||||||
|
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||||
|
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType.includes('int')) {
|
||||||
|
return isBooleanLikeColumn(input.columnName, normalizedDefault) ? 'boolean' : 'integer';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('real') || normalizedType.includes('double') || normalizedType.includes('float') || normalizedType.includes('decimal')) {
|
||||||
|
return 'real';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('json')) {
|
||||||
|
return 'json';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('timestamp') || normalizedType.includes('datetime') || normalizedType === 'date') {
|
||||||
|
return 'datetime';
|
||||||
|
}
|
||||||
|
if (normalizedType.includes('text') || normalizedType.includes('char') || normalizedType.includes('clob')) {
|
||||||
|
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||||
|
if (isJsonLikeColumn(input.columnName)) return 'json';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
if (isDateTimeLikeColumn(input.columnName, normalizedDefault)) return 'datetime';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __schemaMetadataTestUtils = {
|
||||||
|
isBooleanLikeColumn,
|
||||||
|
isDateTimeLikeColumn,
|
||||||
|
isJsonLikeColumn,
|
||||||
|
};
|
||||||
@@ -7,19 +7,19 @@ export interface SiteSchemaInspector {
|
|||||||
execute(sqlText: string): Promise<void>;
|
execute(sqlText: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteColumnCompatibilitySpec = {
|
export type SiteColumnCompatibilitySpec = {
|
||||||
column: string;
|
column: string;
|
||||||
addSql: Record<SiteSchemaDialect, string>;
|
addSql: Record<SiteSchemaDialect, string>;
|
||||||
normalizeSql?: Record<SiteSchemaDialect, string>;
|
normalizeSql?: Record<SiteSchemaDialect, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SiteTableCompatibilitySpec = {
|
export type SiteTableCompatibilitySpec = {
|
||||||
table: string;
|
table: string;
|
||||||
createSql: Record<SiteSchemaDialect, string>;
|
createSql: Record<SiteSchemaDialect, string>;
|
||||||
postCreateSql?: Record<SiteSchemaDialect, string[]>;
|
postCreateSql?: Record<SiteSchemaDialect, string[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
|
export const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
|
||||||
{
|
{
|
||||||
column: 'proxy_url',
|
column: 'proxy_url',
|
||||||
addSql: {
|
addSql: {
|
||||||
@@ -72,7 +72,7 @@ const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const SITE_TABLE_COMPATIBILITY_SPECS: SiteTableCompatibilitySpec[] = [
|
export const SITE_TABLE_COMPATIBILITY_SPECS: SiteTableCompatibilitySpec[] = [
|
||||||
{
|
{
|
||||||
table: 'site_disabled_models',
|
table: 'site_disabled_models',
|
||||||
createSql: {
|
createSql: {
|
||||||
|
|||||||
+4
-2
@@ -22,13 +22,14 @@ import { oauthRoutes } from './routes/api/oauth.js';
|
|||||||
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||||
import { proxyRoutes } from './routes/proxy/router.js';
|
import { proxyRoutes } from './routes/proxy/router.js';
|
||||||
import { startScheduler } from './services/checkinScheduler.js';
|
import { startScheduler } from './services/checkinScheduler.js';
|
||||||
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
|
import * as routeRefreshWorkflow from './services/routeRefreshWorkflow.js';
|
||||||
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
|
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
|
||||||
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||||
|
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
|
||||||
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
|
||||||
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
|
import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
|
||||||
import { reloadBackupWebdavScheduler } from './services/backupService.js';
|
import { reloadBackupWebdavScheduler } from './services/backupService.js';
|
||||||
@@ -337,7 +338,8 @@ try {
|
|||||||
await repairStoredCreatedAtValues();
|
await repairStoredCreatedAtValues();
|
||||||
await migrateSiteApiKeysToAccounts();
|
await migrateSiteApiKeysToAccounts();
|
||||||
await ensureDefaultSitesSeeded();
|
await ensureDefaultSitesSeeded();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await ensureOauthIdentityBackfill();
|
||||||
|
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
|
|
||||||
console.log('Loaded runtime settings overrides');
|
console.log('Loaded runtime settings overrides');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||||
|
import { classifyConversationFileMimeType } from '../../../shared/conversationFileTypes.js';
|
||||||
|
|
||||||
export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native';
|
export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native';
|
||||||
export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses';
|
export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses';
|
||||||
@@ -25,13 +26,6 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyMimeFamily(mimeType: string | null): 'image' | 'audio' | 'document' {
|
|
||||||
const normalized = asTrimmedString(mimeType).toLowerCase();
|
|
||||||
if (normalized.startsWith('image/')) return 'image';
|
|
||||||
if (normalized.startsWith('audio/')) return 'audio';
|
|
||||||
return 'document';
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendConversationFileSummary(
|
function appendConversationFileSummary(
|
||||||
summary: ConversationFileInputSummary,
|
summary: ConversationFileInputSummary,
|
||||||
item: Record<string, unknown>,
|
item: Record<string, unknown>,
|
||||||
@@ -39,7 +33,7 @@ function appendConversationFileSummary(
|
|||||||
const normalizedFile = normalizeInputFileBlock(item);
|
const normalizedFile = normalizeInputFileBlock(item);
|
||||||
if (normalizedFile) {
|
if (normalizedFile) {
|
||||||
const mimeType = inferInputFileMimeType(normalizedFile);
|
const mimeType = inferInputFileMimeType(normalizedFile);
|
||||||
const family = classifyMimeFamily(mimeType);
|
const family = classifyConversationFileMimeType(mimeType);
|
||||||
if (family === 'image') {
|
if (family === 'image') {
|
||||||
summary.hasImage = true;
|
summary.hasImage = true;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export const claudeCodeCliProfile: CliProfileDefinition = {
|
|||||||
id: 'claude_code',
|
id: 'claude_code',
|
||||||
sessionId,
|
sessionId,
|
||||||
traceHint: sessionId,
|
traceHint: sessionId,
|
||||||
|
clientAppId: 'claude_code',
|
||||||
|
clientAppName: 'Claude Code',
|
||||||
|
clientConfidence: 'exact',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { detectCodexOfficialClientApp } from './codexProfile.js';
|
||||||
|
|
||||||
|
describe('detectCodexOfficialClientApp', () => {
|
||||||
|
it('returns null for missing or empty headers', () => {
|
||||||
|
expect(detectCodexOfficialClientApp()).toBeNull();
|
||||||
|
expect(detectCodexOfficialClientApp({})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects official Codex clients from originator prefixes', () => {
|
||||||
|
expect(detectCodexOfficialClientApp({
|
||||||
|
originator: 'codex_exec',
|
||||||
|
})).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('matches headers case-insensitively and from header arrays', () => {
|
||||||
|
expect(detectCodexOfficialClientApp({
|
||||||
|
originator: 'CODEX_EXEC',
|
||||||
|
})).toEqual({
|
||||||
|
clientAppId: 'codex_exec',
|
||||||
|
clientAppName: 'Codex Exec',
|
||||||
|
});
|
||||||
|
expect(detectCodexOfficialClientApp({
|
||||||
|
'user-agent': 'Mozilla/5.0 CODEX_chatgpt_desktop/1.2',
|
||||||
|
})).toEqual({
|
||||||
|
clientAppId: 'codex_chatgpt_desktop',
|
||||||
|
clientAppName: 'Codex Desktop',
|
||||||
|
});
|
||||||
|
expect(detectCodexOfficialClientApp({
|
||||||
|
'user-agent': ['Mozilla/5.0', 'codex_chatgpt_desktop/1.2'],
|
||||||
|
})).toEqual({
|
||||||
|
clientAppId: 'codex_chatgpt_desktop',
|
||||||
|
clientAppName: 'Codex Desktop',
|
||||||
|
});
|
||||||
|
expect(detectCodexOfficialClientApp({
|
||||||
|
originator: ['ignored', 'other-client'],
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-official Codex clients', () => {
|
||||||
|
expect(detectCodexOfficialClientApp({
|
||||||
|
'user-agent': 'OpenClaw/1.0',
|
||||||
|
})).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { CliProfileDefinition, DetectCliProfileInput } from './types.js';
|
import type { CliProfileDefinition, DetectCliProfileInput } from './types.js';
|
||||||
|
|
||||||
|
type CodexOfficialClientApp = {
|
||||||
|
clientAppId: string;
|
||||||
|
clientAppName: string;
|
||||||
|
};
|
||||||
|
|
||||||
const CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES = [
|
const CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES = [
|
||||||
'codex_cli_rs/',
|
'codex_cli_rs/',
|
||||||
'codex_vscode/',
|
'codex_vscode/',
|
||||||
@@ -16,33 +21,88 @@ const CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES = [
|
|||||||
'codex ',
|
'codex ',
|
||||||
];
|
];
|
||||||
|
|
||||||
function headerValueToString(value: unknown): string | null {
|
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 headerValueToStrings(value: unknown): string[] {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed || null;
|
return trimmed ? [trimmed] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
|
const values: string[] = [];
|
||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
if (typeof item !== 'string') continue;
|
if (typeof item !== 'string') continue;
|
||||||
const trimmed = item.trim();
|
const trimmed = item.trim();
|
||||||
if (trimmed) return trimmed;
|
if (trimmed) values.push(trimmed);
|
||||||
}
|
}
|
||||||
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerValueToString(value: unknown): string | null {
|
||||||
|
return headerValueToStrings(value)[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
|
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
|
||||||
if (!headers) return null;
|
return getHeaderValues(headers, targetKey)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeaderValues(headers: Record<string, unknown> | undefined, targetKey: string): string[] {
|
||||||
|
if (!headers) return [];
|
||||||
const normalizedTarget = targetKey.trim().toLowerCase();
|
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||||
|
const values: string[] = [];
|
||||||
|
|
||||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||||
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
|
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
|
||||||
return headerValueToString(rawValue);
|
values.push(...headerValueToStrings(rawValue));
|
||||||
}
|
}
|
||||||
|
return values;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasHeaderPrefix(headers: Record<string, unknown> | undefined, prefix: string): boolean {
|
function hasHeaderPrefix(headers: Record<string, unknown> | undefined, prefix: string): boolean {
|
||||||
@@ -54,16 +114,18 @@ function hasHeaderPrefix(headers: Record<string, unknown> | undefined, prefix: s
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesHeaderPrefixes(value: string | null, prefixes: string[]): boolean {
|
function matchesHeaderPrefixes(value: string | string[] | null, prefixes: readonly string[]): boolean {
|
||||||
const normalizedValue = value?.trim().toLowerCase() || '';
|
const values = Array.isArray(value)
|
||||||
if (!normalizedValue) return false;
|
? value.map((item) => item.trim().toLowerCase()).filter(Boolean)
|
||||||
|
: [value?.trim().toLowerCase() || ''].filter(Boolean);
|
||||||
|
if (values.length === 0) return false;
|
||||||
|
|
||||||
return prefixes.some((prefix) => {
|
return values.some((normalizedValue) => prefixes.some((prefix) => {
|
||||||
const normalizedPrefix = prefix.trim().toLowerCase();
|
const normalizedPrefix = prefix.trim().toLowerCase();
|
||||||
if (!normalizedPrefix) return false;
|
if (!normalizedPrefix) return false;
|
||||||
return normalizedValue.startsWith(normalizedPrefix)
|
return normalizedValue.startsWith(normalizedPrefix)
|
||||||
|| normalizedValue.includes(normalizedPrefix);
|
|| normalizedValue.includes(normalizedPrefix);
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCodexPath(path: string): boolean {
|
function isCodexPath(path: string): boolean {
|
||||||
@@ -73,6 +135,21 @@ function isCodexPath(path: string): boolean {
|
|||||||
|| normalizedPath.startsWith('/v1/messages');
|
|| normalizedPath.startsWith('/v1/messages');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function detectCodexOfficialClientApp(
|
||||||
|
headers?: Record<string, unknown>,
|
||||||
|
): CodexOfficialClientApp | null {
|
||||||
|
for (const rule of CODEX_OFFICIAL_CLIENT_APP_RULES) {
|
||||||
|
const matchesOriginator = matchesHeaderPrefixes(getHeaderValues(headers, 'originator'), rule.originatorPrefixes);
|
||||||
|
const matchesUserAgent = matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), rule.userAgentPrefixes);
|
||||||
|
if (!matchesOriginator && !matchesUserAgent) continue;
|
||||||
|
return {
|
||||||
|
clientAppId: rule.id,
|
||||||
|
clientAppName: rule.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||||
return isCodexRequest({
|
return isCodexRequest({
|
||||||
downstreamPath: '/v1/responses',
|
downstreamPath: '/v1/responses',
|
||||||
@@ -89,9 +166,9 @@ export function isCodexRequest(input: DetectCliProfileInput): boolean {
|
|||||||
const headers = input.headers;
|
const headers = input.headers;
|
||||||
if (!headers) return false;
|
if (!headers) return false;
|
||||||
|
|
||||||
const originator = getHeaderValue(headers, 'originator');
|
const originator = getHeaderValues(headers, 'originator');
|
||||||
if (matchesHeaderPrefixes(originator, CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES)) return true;
|
if (matchesHeaderPrefixes(originator, CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES)) return true;
|
||||||
if (matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true;
|
if (matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true;
|
||||||
if (getHeaderValue(headers, 'openai-beta')) return true;
|
if (getHeaderValue(headers, 'openai-beta')) return true;
|
||||||
if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
|
if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
|
||||||
if (getCodexSessionId(headers)) return true;
|
if (getCodexSessionId(headers)) return true;
|
||||||
@@ -112,9 +189,21 @@ export const codexCliProfile: CliProfileDefinition = {
|
|||||||
if (!isCodexRequest(input)) return null;
|
if (!isCodexRequest(input)) return null;
|
||||||
|
|
||||||
const sessionId = getCodexSessionId(input.headers) || undefined;
|
const sessionId = getCodexSessionId(input.headers) || undefined;
|
||||||
|
const clientApp = detectCodexOfficialClientApp(input.headers);
|
||||||
return {
|
return {
|
||||||
id: 'codex',
|
id: 'codex',
|
||||||
...(sessionId ? { sessionId, traceHint: sessionId } : {}),
|
...(sessionId ? { sessionId, traceHint: sessionId } : {}),
|
||||||
|
...(clientApp
|
||||||
|
? {
|
||||||
|
clientAppId: clientApp.clientAppId,
|
||||||
|
clientAppName: clientApp.clientAppName,
|
||||||
|
clientConfidence: 'exact' as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
clientAppId: 'codex',
|
||||||
|
clientAppName: 'Codex',
|
||||||
|
clientConfidence: 'heuristic' as const,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export const geminiCliProfile: CliProfileDefinition = {
|
|||||||
if (input.body !== undefined && !hasGeminiCliBodyShape(input.body)) return null;
|
if (input.body !== undefined && !hasGeminiCliBodyShape(input.body)) return null;
|
||||||
return {
|
return {
|
||||||
id: 'gemini_cli',
|
id: 'gemini_cli',
|
||||||
|
clientAppId: 'gemini_cli',
|
||||||
|
clientAppName: 'Gemini CLI',
|
||||||
|
clientConfidence: 'exact',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ describe('detectCliProfile', () => {
|
|||||||
id: 'codex',
|
id: 'codex',
|
||||||
sessionId: 'codex-session-123',
|
sessionId: 'codex-session-123',
|
||||||
traceHint: 'codex-session-123',
|
traceHint: 'codex-session-123',
|
||||||
|
clientAppId: 'codex_cli_rs',
|
||||||
|
clientAppName: 'Codex CLI',
|
||||||
|
clientConfidence: 'exact',
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsResponsesCompact: true,
|
supportsResponsesCompact: true,
|
||||||
supportsResponsesWebsocketIncremental: true,
|
supportsResponsesWebsocketIncremental: true,
|
||||||
@@ -79,6 +82,9 @@ describe('detectCliProfile', () => {
|
|||||||
id: 'claude_code',
|
id: 'claude_code',
|
||||||
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||||
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||||
|
clientAppId: 'claude_code',
|
||||||
|
clientAppName: 'Claude Code',
|
||||||
|
clientConfidence: 'exact',
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsResponsesCompact: false,
|
supportsResponsesCompact: false,
|
||||||
supportsResponsesWebsocketIncremental: false,
|
supportsResponsesWebsocketIncremental: false,
|
||||||
@@ -98,6 +104,9 @@ describe('detectCliProfile', () => {
|
|||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
id: 'gemini_cli',
|
id: 'gemini_cli',
|
||||||
|
clientAppId: 'gemini_cli',
|
||||||
|
clientAppName: 'Gemini CLI',
|
||||||
|
clientConfidence: 'exact',
|
||||||
capabilities: {
|
capabilities: {
|
||||||
supportsResponsesCompact: false,
|
supportsResponsesCompact: false,
|
||||||
supportsResponsesWebsocketIncremental: false,
|
supportsResponsesWebsocketIncremental: false,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export type CliProfileCapabilities = {
|
|||||||
echoesTurnState: boolean;
|
echoesTurnState: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CliProfileClientConfidence = 'exact' | 'heuristic';
|
||||||
|
|
||||||
export type DetectCliProfileInput = {
|
export type DetectCliProfileInput = {
|
||||||
downstreamPath: string;
|
downstreamPath: string;
|
||||||
headers?: Record<string, unknown>;
|
headers?: Record<string, unknown>;
|
||||||
@@ -22,6 +24,9 @@ export type DetectedCliProfile = {
|
|||||||
id: CliProfileId;
|
id: CliProfileId;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
traceHint?: string;
|
traceHint?: string;
|
||||||
|
clientAppId?: string;
|
||||||
|
clientAppName?: string;
|
||||||
|
clientConfidence?: CliProfileClientConfidence;
|
||||||
capabilities: CliProfileCapabilities;
|
capabilities: CliProfileCapabilities;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +1,5 @@
|
|||||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
||||||
|
import { buildClaudeRuntimeHeaders } from './headerUtils.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const claudeProviderProfile: ProviderProfile = {
|
export const claudeProviderProfile: ProviderProfile = {
|
||||||
id: 'claude',
|
id: 'claude',
|
||||||
|
|||||||
@@ -1,130 +1,34 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
|
import { buildCodexRuntimeHeaders, getInputHeader } from './headerUtils.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';
|
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function headerValueToString(value: unknown): string | null {
|
export const codexProviderProfile: ProviderProfile = {
|
||||||
if (typeof value === 'string') {
|
id: 'codex',
|
||||||
const trimmed = value.trim();
|
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||||
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;
|
|
||||||
const isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex';
|
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 websocketTransport = getInputHeader(input.baseHeaders, 'x-metapi-responses-websocket-transport') === '1';
|
||||||
|
const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : '';
|
||||||
const configuredBetaFeatures = (
|
const configuredBetaFeatures = (
|
||||||
isCodexOauth && websocketTransport
|
isCodexOauth && websocketTransport
|
||||||
? asTrimmedString(config.codexHeaderDefaults.betaFeatures)
|
? 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({
|
const headers = buildCodexRuntimeHeaders({
|
||||||
baseHeaders: input.baseHeaders,
|
baseHeaders: input.baseHeaders,
|
||||||
providerHeaders: input.providerHeaders,
|
providerHeaders: input.providerHeaders,
|
||||||
oauthProvider: input.oauthProvider,
|
|
||||||
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
||||||
continuityKey: asTrimmedString(input.codexSessionCacheKey) || 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 codexSessionId = getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id');
|
||||||
const shouldInjectDerivedPromptCacheKey = !!codexSessionId
|
const shouldInjectDerivedPromptCacheKey = !!codexSessionId
|
||||||
|
|||||||
@@ -1,60 +1,10 @@
|
|||||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js';
|
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js';
|
||||||
|
import { buildGeminiCliRuntimeHeaders } from './headerUtils.js';
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
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 {
|
function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction {
|
||||||
if (action) return action;
|
if (action) return action;
|
||||||
return stream ? 'streamGenerateContent' : 'generateContent';
|
return stream ? 'streamGenerateContent' : 'generateContent';
|
||||||
@@ -74,25 +24,12 @@ export const geminiCliProviderProfile: ProviderProfile = {
|
|||||||
throw new Error('gemini-cli oauth project id missing');
|
throw new Error('gemini-cli oauth project id missing');
|
||||||
}
|
}
|
||||||
const action = resolveAction(input.action, input.stream);
|
const action = resolveAction(input.action, input.stream);
|
||||||
const apiClient = (
|
const headers = buildGeminiCliRuntimeHeaders({
|
||||||
getInputHeader(input.providerHeaders, 'x-goog-api-client')
|
baseHeaders: input.baseHeaders,
|
||||||
|| getInputHeader(input.baseHeaders, 'x-goog-api-client')
|
providerHeaders: input.providerHeaders,
|
||||||
);
|
modelName: input.modelName,
|
||||||
const userAgent = buildGeminiCLIUserAgent(
|
stream: action === 'streamGenerateContent',
|
||||||
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';
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
path: resolvePath(action),
|
path: resolvePath(action),
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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('beta-a,beta-b,custom-a,custom-b,beta-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
authorization: 'Bearer stale-token',
|
||||||
|
},
|
||||||
|
claudeHeaders: {
|
||||||
|
'anthropic-beta': 'custom-beta',
|
||||||
|
'x-api-key': 'stale-api-key',
|
||||||
|
'user-agent': 'custom-agent',
|
||||||
|
},
|
||||||
|
anthropicVersion: '2023-06-01',
|
||||||
|
stream: true,
|
||||||
|
isClaudeOauthUpstream: true,
|
||||||
|
tokenValue: 'oauth-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(headers['anthropic-version']).toBe('2023-06-01');
|
||||||
|
expect(headers['anthropic-beta']).toContain('claude-code-20250219');
|
||||||
|
expect(headers['anthropic-beta']).toContain('custom-beta');
|
||||||
|
expect(headers.Authorization).toBe('Bearer oauth-token');
|
||||||
|
expect(headers.authorization).toBeUndefined();
|
||||||
|
expect(headers['x-api-key']).toBeUndefined();
|
||||||
|
expect(headers['user-agent']).toBeUndefined();
|
||||||
|
expect(headers['User-Agent']).toBe('custom-agent');
|
||||||
|
expect(headers.Accept).toBe('text/event-stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
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,272 @@
|
|||||||
|
import { pbkdf2Sync, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const CODEX_CLIENT_VERSION = '0.101.0';
|
||||||
|
const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLowerCaseHeaderMap(
|
||||||
|
sources: Array<Record<string, unknown> | Record<string, string> | undefined>,
|
||||||
|
shouldSkip: (key: string) => boolean,
|
||||||
|
): Record<string, string> {
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source) continue;
|
||||||
|
for (const [key, value] of Object.entries(source)) {
|
||||||
|
const normalizedValue = headerValueToString(value);
|
||||||
|
if (!normalizedValue) continue;
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
if (shouldSkip(normalizedKey)) continue;
|
||||||
|
normalized[normalizedKey] = normalizedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuidFromSeed(seed: string): string {
|
||||||
|
const derived = pbkdf2Sync(seed, 'metapi-runtime-header-seed', 10_000, 16, 'sha256');
|
||||||
|
const bytes = new Uint8Array(derived);
|
||||||
|
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 seen = new Set<string>();
|
||||||
|
const merged: string[] = [];
|
||||||
|
for (const source of [defaultValue, explicitValue ?? '', ...extraBetas]) {
|
||||||
|
for (const entry of source.split(',')) {
|
||||||
|
const normalized = entry.trim();
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
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: 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 passthroughHeaders = normalizeLowerCaseHeaderMap(
|
||||||
|
[input.baseHeaders, input.claudeHeaders],
|
||||||
|
(key) => (
|
||||||
|
key === 'accept'
|
||||||
|
|| key === 'accept-encoding'
|
||||||
|
|| key === 'anthropic-beta'
|
||||||
|
|| key === 'anthropic-dangerous-direct-browser-access'
|
||||||
|
|| key === 'anthropic-version'
|
||||||
|
|| key === 'authorization'
|
||||||
|
|| key === 'connection'
|
||||||
|
|| key === 'user-agent'
|
||||||
|
|| key === 'x-api-key'
|
||||||
|
|| key === 'x-app'
|
||||||
|
|| key.startsWith('x-stainless-')
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...passthroughHeaders,
|
||||||
|
'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 authorization = getInputHeader(input.baseHeaders, 'authorization');
|
||||||
|
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 ? { Authorization: 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,8 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { reportProxyAllFailed } from '../../services/alertService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
|
||||||
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||||
import {
|
import {
|
||||||
buildClaudeCountTokensUpstreamRequest,
|
buildClaudeCountTokensUpstreamRequest,
|
||||||
@@ -21,12 +16,8 @@ import {
|
|||||||
getDownstreamRoutingPolicy,
|
getDownstreamRoutingPolicy,
|
||||||
recordDownstreamCostUsage,
|
recordDownstreamCostUsage,
|
||||||
} from '../../routes/proxy/downstreamPolicy.js';
|
} from '../../routes/proxy/downstreamPolicy.js';
|
||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.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 { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||||
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
|
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
|
||||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||||
@@ -37,9 +28,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildOauthProviderHeaders,
|
buildOauthProviderHeaders,
|
||||||
} from '../../services/oauth/service.js';
|
} from '../../services/oauth/service.js';
|
||||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
|
||||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
|
||||||
import {
|
import {
|
||||||
collectResponsesFinalPayloadFromSse,
|
collectResponsesFinalPayloadFromSse,
|
||||||
collectResponsesFinalPayloadFromSseText,
|
collectResponsesFinalPayloadFromSseText,
|
||||||
@@ -50,10 +39,15 @@ import {
|
|||||||
createGeminiCliStreamReader,
|
createGeminiCliStreamReader,
|
||||||
unwrapGeminiCliPayload,
|
unwrapGeminiCliPayload,
|
||||||
} from '../../routes/proxy/geminiCliCompat.js';
|
} from '../../routes/proxy/geminiCliCompat.js';
|
||||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
|
||||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||||
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
import {
|
||||||
|
createSurfaceFailureToolkit,
|
||||||
|
createSurfaceDispatchRequest,
|
||||||
|
recordSurfaceSuccess,
|
||||||
|
selectSurfaceChannelForAttempt,
|
||||||
|
trySurfaceOauthRefreshRecovery,
|
||||||
|
} from './sharedSurface.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -114,19 +108,24 @@ export async function handleChatSurfaceRequest(
|
|||||||
proxyToken: getProxyAuthContext(request)?.token || null,
|
proxyToken: getProxyAuthContext(request)?.token || null,
|
||||||
});
|
});
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
|
const failureToolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'chat',
|
||||||
|
downstreamPath,
|
||||||
|
maxRetries: MAX_RETRIES,
|
||||||
|
clientContext,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
});
|
||||||
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
while (retryCount <= MAX_RETRIES) {
|
while (retryCount <= MAX_RETRIES) {
|
||||||
let selected = retryCount === 0
|
const selected = await selectSurfaceChannelForAttempt({
|
||||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
requestedModel,
|
||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
downstreamPolicy,
|
||||||
|
excludeChannelIds,
|
||||||
if (!selected && retryCount === 0) {
|
retryCount,
|
||||||
await refreshModelsAndRebuildRoutes();
|
});
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
await reportProxyAllFailed({
|
await reportProxyAllFailed({
|
||||||
@@ -141,7 +140,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
excludeChannelIds.push(selected.channel.id);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
const modelName = selected.actualModel || requestedModel;
|
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 isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||||
const endpointCandidates = [
|
const endpointCandidates = [
|
||||||
...await resolveUpstreamEndpointCandidates(
|
...await resolveUpstreamEndpointCandidates(
|
||||||
@@ -170,7 +169,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
};
|
};
|
||||||
const buildProviderHeaders = () => (
|
const buildProviderHeaders = () => (
|
||||||
buildOauthProviderHeaders({
|
buildOauthProviderHeaders({
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
account: selected.account,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -204,22 +203,10 @@ export async function handleChatSurfaceRequest(
|
|||||||
runtime: endpointRequest.runtime,
|
runtime: endpointRequest.runtime,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig);
|
const dispatchRequest = createSurfaceDispatchRequest({
|
||||||
const dispatchRequest = (
|
site: selected.site,
|
||||||
compatibilityRequest: BuiltEndpointRequest,
|
accountExtraConfig: selected.account.extraConfig,
|
||||||
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 endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
|
const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
|
||||||
downstreamFormat,
|
downstreamFormat,
|
||||||
endpointCandidates,
|
endpointCandidates,
|
||||||
@@ -235,28 +222,15 @@ export async function handleChatSurfaceRequest(
|
|||||||
});
|
});
|
||||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||||
if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) {
|
if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) {
|
||||||
try {
|
const recovered = await trySurfaceOauthRefreshRecovery({
|
||||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
ctx,
|
||||||
selected.tokenValue = refreshed.accessToken;
|
selected,
|
||||||
selected.account = {
|
siteUrl: selected.site.url,
|
||||||
...selected.account,
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
accessToken: refreshed.accessToken,
|
dispatchRequest,
|
||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
});
|
||||||
};
|
if (recovered?.upstream?.ok) {
|
||||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
return recovered;
|
||||||
const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path);
|
|
||||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
|
||||||
if (refreshedResponse.ok) {
|
|
||||||
return {
|
|
||||||
upstream: refreshedResponse,
|
|
||||||
upstreamPath: refreshedRequest.path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
ctx.request = refreshedRequest;
|
|
||||||
ctx.response = refreshedResponse;
|
|
||||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
|
||||||
} catch {
|
|
||||||
return endpointStrategy.tryRecover(ctx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return endpointStrategy.tryRecover(ctx);
|
return endpointStrategy.tryRecover(ctx);
|
||||||
@@ -286,82 +260,34 @@ export async function handleChatSurfaceRequest(
|
|||||||
},
|
},
|
||||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||||
onDowngrade: (ctx) => {
|
onDowngrade: (ctx) => {
|
||||||
logProxy(
|
return failureToolkit.log({
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
modelRequested: requestedModel,
|
||||||
'failed',
|
status: 'failed',
|
||||||
ctx.response.status,
|
httpStatus: ctx.response.status,
|
||||||
Date.now() - startTime,
|
latencyMs: Date.now() - startTime,
|
||||||
ctx.errText,
|
errorMessage: ctx.errText,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
});
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!endpointResult.ok) {
|
if (!endpointResult.ok) {
|
||||||
const status = endpointResult.status || 502;
|
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||||
const errText = endpointResult.errText || 'unknown error';
|
|
||||||
const rawErrText = endpointResult.rawErrText || errText;
|
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
|
||||||
status,
|
|
||||||
errorText: rawErrText,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
status,
|
status: endpointResult.status || 502,
|
||||||
Date.now() - startTime,
|
errText: endpointResult.errText || 'unknown error',
|
||||||
errText,
|
rawErrText: endpointResult.rawErrText,
|
||||||
|
latencyMs: Date.now() - startTime,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
await recordOauthQuotaResetHint({
|
|
||||||
accountId: selected.account.id,
|
|
||||||
statusCode: status,
|
|
||||||
errorText: rawErrText,
|
|
||||||
});
|
});
|
||||||
|
if (failureOutcome.action === 'retry') {
|
||||||
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) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
await reportProxyAllFailed({
|
|
||||||
model: requestedModel,
|
|
||||||
reason: `upstream returned HTTP ${status}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.code(status).send({
|
|
||||||
error: { message: errText, type: 'upstream_error' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = endpointResult.upstream;
|
const upstream = endpointResult.upstream;
|
||||||
@@ -418,25 +344,18 @@ export async function handleChatSurfaceRequest(
|
|||||||
);
|
);
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id, modelName);
|
await failureToolkit.recordStreamFailure({
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
200,
|
errorMessage: streamResult.errorMessage,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
streamResult.errorMessage,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
});
|
||||||
null,
|
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -454,71 +373,41 @@ export async function handleChatSurfaceRequest(
|
|||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||||
status: failure.status,
|
|
||||||
errorText: failure.reason,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
failure.status,
|
failure,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
failure.reason,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
});
|
||||||
null,
|
if (failureOutcome.action === 'retry') {
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
await reportProxyAllFailed({
|
|
||||||
model: requestedModel,
|
|
||||||
reason: failure.reason,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.code(failure.status).send({
|
|
||||||
error: { message: failure.reason, type: 'upstream_error' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startSseResponse();
|
startSseResponse();
|
||||||
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
await failureToolkit.recordStreamFailure({
|
||||||
status: 502,
|
|
||||||
errorText: streamResult.errorMessage,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
200,
|
errorMessage: streamResult.errorMessage,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
streamResult.errorMessage,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
runtimeFailureStatus: 502,
|
||||||
null,
|
});
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -550,29 +439,19 @@ export async function handleChatSurfaceRequest(
|
|||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
await failureToolkit.recordStreamFailure({
|
||||||
status: 502,
|
|
||||||
errorText: streamResult.errorMessage,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
200,
|
errorMessage: streamResult.errorMessage,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
streamResult.errorMessage,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
runtimeFailureStatus: 502,
|
||||||
null,
|
});
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,50 +462,23 @@ export async function handleChatSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
await recordSurfaceSuccess({
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
tokenValue: selected.tokenValue,
|
|
||||||
tokenName: selected.tokenName,
|
|
||||||
modelName,
|
|
||||||
requestStartedAtMs: startTime,
|
|
||||||
requestEndedAtMs: startTime + latency,
|
|
||||||
localLatencyMs: latency,
|
|
||||||
usage: {
|
|
||||||
promptTokens: parsedUsage.promptTokens,
|
|
||||||
completionTokens: parsedUsage.completionTokens,
|
|
||||||
totalTokens: parsedUsage.totalTokens,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
modelName,
|
|
||||||
parsedUsage,
|
|
||||||
resolvedUsage,
|
|
||||||
});
|
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'success',
|
modelName,
|
||||||
200,
|
parsedUsage,
|
||||||
latency,
|
requestStartedAtMs: startTime,
|
||||||
null,
|
latencyMs: latency,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
upstreamPath: successfulUpstreamPath,
|
||||||
resolvedUsage.promptTokens,
|
logSuccess: failureToolkit.log,
|
||||||
resolvedUsage.completionTokens,
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
resolvedUsage.totalTokens,
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
estimatedCost,
|
},
|
||||||
billingDetails,
|
bestEffortMetrics: {
|
||||||
successfulUpstreamPath,
|
errorLabel: '[proxy/chat] failed to record success metrics',
|
||||||
clientContext,
|
},
|
||||||
downstreamApiKeyId,
|
});
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,133 +510,60 @@ export async function handleChatSurfaceRequest(
|
|||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||||
status: failure.status,
|
|
||||||
errorText: failure.reason,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
failure.status,
|
failure,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
failure.reason,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
});
|
||||||
null,
|
if (failureOutcome.action === 'retry') {
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
await reportProxyAllFailed({
|
|
||||||
model: requestedModel,
|
|
||||||
reason: failure.reason,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.code(failure.status).send({
|
|
||||||
error: { message: failure.reason, type: 'upstream_error' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
|
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
|
||||||
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
|
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
|
||||||
|
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
await recordSurfaceSuccess({
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
tokenValue: selected.tokenValue,
|
|
||||||
tokenName: selected.tokenName,
|
|
||||||
modelName,
|
|
||||||
requestStartedAtMs: startTime,
|
|
||||||
requestEndedAtMs: startTime + latency,
|
|
||||||
localLatencyMs: latency,
|
|
||||||
usage: {
|
|
||||||
promptTokens: parsedUsage.promptTokens,
|
|
||||||
completionTokens: parsedUsage.completionTokens,
|
|
||||||
totalTokens: parsedUsage.totalTokens,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
modelName,
|
|
||||||
parsedUsage,
|
|
||||||
resolvedUsage,
|
|
||||||
});
|
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'success',
|
modelName,
|
||||||
200,
|
parsedUsage,
|
||||||
latency,
|
requestStartedAtMs: startTime,
|
||||||
null,
|
latencyMs: latency,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
upstreamPath: successfulUpstreamPath,
|
||||||
resolvedUsage.promptTokens,
|
logSuccess: failureToolkit.log,
|
||||||
resolvedUsage.completionTokens,
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
resolvedUsage.totalTokens,
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
estimatedCost,
|
},
|
||||||
billingDetails,
|
bestEffortMetrics: {
|
||||||
successfulUpstreamPath,
|
errorLabel: '[proxy/chat] failed to record success metrics',
|
||||||
clientContext,
|
},
|
||||||
downstreamApiKeyId,
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return reply.send(downstreamResponse);
|
return reply.send(downstreamResponse);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||||
errorText: err?.message,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
0,
|
errorMessage: err?.message || 'network failure',
|
||||||
Date.now() - startTime,
|
latencyMs: Date.now() - startTime,
|
||||||
err?.message || 'network error',
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
});
|
||||||
0,
|
if (failureOutcome.action === 'retry') {
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (retryCount < MAX_RETRIES) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -845,18 +624,23 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
});
|
});
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
|
const failureToolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'chat',
|
||||||
|
downstreamPath,
|
||||||
|
maxRetries: MAX_RETRIES,
|
||||||
|
clientContext,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
while (retryCount <= MAX_RETRIES) {
|
while (retryCount <= MAX_RETRIES) {
|
||||||
let selected = retryCount === 0
|
const selected = await selectSurfaceChannelForAttempt({
|
||||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
requestedModel,
|
||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
downstreamPolicy,
|
||||||
|
excludeChannelIds,
|
||||||
if (!selected && retryCount === 0) {
|
retryCount,
|
||||||
await refreshModelsAndRebuildRoutes();
|
});
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
await reportProxyAllFailed({
|
await reportProxyAllFailed({
|
||||||
@@ -891,7 +675,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
const oauth = getOauthInfoFromAccount(selected.account);
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const buildRequest = () => {
|
const buildRequest = () => {
|
||||||
@@ -914,37 +698,32 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let upstreamRequest = buildRequest();
|
let upstreamRequest = buildRequest();
|
||||||
let upstream = await dispatchRuntimeRequest({
|
const dispatchRequest = createSurfaceDispatchRequest({
|
||||||
siteUrl: selected.site.url,
|
site: selected.site,
|
||||||
request: upstreamRequest,
|
accountExtraConfig: selected.account.extraConfig,
|
||||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: requestForFetch.headers,
|
|
||||||
body: JSON.stringify(requestForFetch.body),
|
|
||||||
}, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)),
|
|
||||||
});
|
});
|
||||||
|
let upstream = await dispatchRequest(upstreamRequest);
|
||||||
|
|
||||||
if ((upstream.status === 401 || upstream.status === 403) && oauth) {
|
if ((upstream.status === 401 || upstream.status === 403) && oauth) {
|
||||||
try {
|
const recoverContext = {
|
||||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
|
||||||
selected.tokenValue = refreshed.accessToken;
|
|
||||||
selected.account = {
|
|
||||||
...selected.account,
|
|
||||||
accessToken: refreshed.accessToken,
|
|
||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
|
||||||
};
|
|
||||||
upstreamRequest = buildRequest();
|
|
||||||
upstream = await dispatchRuntimeRequest({
|
|
||||||
siteUrl: selected.site.url,
|
|
||||||
request: upstreamRequest,
|
request: upstreamRequest,
|
||||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
response: upstream,
|
||||||
method: 'POST',
|
rawErrText: '',
|
||||||
headers: requestForFetch.headers,
|
};
|
||||||
body: JSON.stringify(requestForFetch.body),
|
const recovered = await trySurfaceOauthRefreshRecovery({
|
||||||
}, resolveChannelProxyUrl(selected.site, selected.account.extraConfig)),
|
ctx: recoverContext,
|
||||||
|
selected,
|
||||||
|
siteUrl: selected.site.url,
|
||||||
|
buildRequest: () => buildRequest(),
|
||||||
|
dispatchRequest,
|
||||||
|
captureFailureBody: false,
|
||||||
});
|
});
|
||||||
} catch {
|
if (recovered?.upstream?.ok) {
|
||||||
// Fall through to the regular upstream error handling below.
|
upstreamRequest = buildRequest();
|
||||||
|
upstream = recovered.upstream;
|
||||||
|
} else {
|
||||||
|
upstreamRequest = recoverContext.request;
|
||||||
|
upstream = recoverContext.response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,148 +738,50 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||||
status: upstream.status,
|
|
||||||
errorText: typeof payload === 'string' ? payload : text,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
upstream.status,
|
status: upstream.status,
|
||||||
latency,
|
errText: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
||||||
typeof payload === 'string' ? payload : JSON.stringify(payload),
|
rawErrText: typeof payload === 'string' ? payload : text,
|
||||||
|
latencyMs: latency,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
});
|
||||||
0,
|
if (failureOutcome.action === 'retry') {
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
upstreamRequest.path,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return reply.code(upstream.status).type(contentType).send(payload);
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
|
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
|
||||||
recordDownstreamCostUsage(request, 0);
|
recordDownstreamCostUsage(request, 0);
|
||||||
logProxy(
|
await failureToolkit.log({
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
modelRequested: requestedModel,
|
||||||
'success',
|
status: 'success',
|
||||||
upstream.status,
|
httpStatus: upstream.status,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
null,
|
errorMessage: null,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
upstreamPath: upstreamRequest.path,
|
||||||
0,
|
});
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
upstreamRequest.path,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
return reply.code(upstream.status).type(contentType).send(payload);
|
return reply.code(upstream.status).type(contentType).send(payload);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||||
errorText: error?.message,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
0,
|
errorMessage: error?.message || 'network failure',
|
||||||
Date.now() - startTime,
|
latencyMs: Date.now() - startTime,
|
||||||
error?.message || 'network error',
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
});
|
||||||
0,
|
if (failureOutcome.action === 'retry') {
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
if (retryCount < MAX_RETRIES) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
return reply.code(502).send({
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
error: {
|
|
||||||
message: `Upstream error: ${error?.message || 'network failure'}`,
|
|
||||||
type: 'upstream_error',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,6 +8,10 @@ import {
|
|||||||
softDeleteProxyFileByPublicIdForOwner,
|
softDeleteProxyFileByPublicIdForOwner,
|
||||||
} from '../../services/proxyFileStore.js';
|
} from '../../services/proxyFileStore.js';
|
||||||
import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js';
|
import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js';
|
||||||
|
import {
|
||||||
|
isSupportedConversationFileMimeType,
|
||||||
|
resolveConversationFileMimeType,
|
||||||
|
} from '../../../shared/conversationFileTypes.js';
|
||||||
|
|
||||||
function invalidRequest(reply: FastifyReply, message: string) {
|
function invalidRequest(reply: FastifyReply, message: string) {
|
||||||
return reply.code(400).send({ error: { message, type: 'invalid_request_error' } });
|
return reply.code(400).send({ error: { message, type: 'invalid_request_error' } });
|
||||||
@@ -43,30 +47,6 @@ function toFileObject(record: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMimeTypeFromFilename(filename: string): string {
|
|
||||||
const normalized = filename.trim().toLowerCase();
|
|
||||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
|
||||||
if (normalized.endsWith('.txt')) return 'text/plain';
|
|
||||||
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
|
|
||||||
if (normalized.endsWith('.json')) return 'application/json';
|
|
||||||
if (normalized.endsWith('.png')) return 'image/png';
|
|
||||||
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
|
||||||
if (normalized.endsWith('.gif')) return 'image/gif';
|
|
||||||
if (normalized.endsWith('.webp')) return 'image/webp';
|
|
||||||
if (normalized.endsWith('.wav')) return 'audio/wav';
|
|
||||||
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
|
|
||||||
return 'application/octet-stream';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSupportedProxyFileMimeType(mimeType: string): boolean {
|
|
||||||
return mimeType === 'application/pdf'
|
|
||||||
|| mimeType === 'text/plain'
|
|
||||||
|| mimeType === 'text/markdown'
|
|
||||||
|| mimeType === 'application/json'
|
|
||||||
|| mimeType.startsWith('image/')
|
|
||||||
|| mimeType.startsWith('audio/');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function filesProxyRoute(app: FastifyInstance) {
|
export async function filesProxyRoute(app: FastifyInstance) {
|
||||||
ensureMultipartBufferParser(app);
|
ensureMultipartBufferParser(app);
|
||||||
|
|
||||||
@@ -87,8 +67,8 @@ export async function filesProxyRoute(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = fileEntry.name || 'upload.bin';
|
const filename = fileEntry.name || 'upload.bin';
|
||||||
const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase();
|
const mimeType = resolveConversationFileMimeType(fileEntry.type, filename);
|
||||||
if (!isSupportedProxyFileMimeType(mimeType)) {
|
if (!isSupportedConversationFileMimeType(mimeType)) {
|
||||||
return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`);
|
return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
|||||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.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 { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js';
|
import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js';
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
@@ -278,7 +278,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
if (!isDirectGeminiFamilyPlatform(selected.site.platform)) {
|
if (!isDirectGeminiFamilyPlatform(selected.site.platform)) {
|
||||||
let models = await readRouteAwareGeminiModels(request);
|
let models = await readRouteAwareGeminiModels(request);
|
||||||
if (models.length <= 0) {
|
if (models.length <= 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
models = await readRouteAwareGeminiModels(request);
|
models = await readRouteAwareGeminiModels(request);
|
||||||
}
|
}
|
||||||
return reply.code(200).send({ models });
|
return reply.code(200).send({ models });
|
||||||
@@ -407,7 +407,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
|
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
|
||||||
actualModel,
|
actualModel,
|
||||||
);
|
);
|
||||||
let oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
let oauth = getOauthInfoFromAccount(selected.account);
|
||||||
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
|
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
|
||||||
const isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
|
const isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
|
||||||
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
|
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
|
||||||
@@ -463,7 +463,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
...(isStreamAction ? { Accept: 'text/event-stream' } : {}),
|
...(isStreamAction ? { Accept: 'text/event-stream' } : {}),
|
||||||
Authorization: `Bearer ${selected.tokenValue}`,
|
Authorization: `Bearer ${selected.tokenValue}`,
|
||||||
...buildOauthProviderHeaders({
|
...buildOauthProviderHeaders({
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
account: selected.account,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -523,7 +523,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
accessToken: refreshed.accessToken,
|
accessToken: refreshed.accessToken,
|
||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||||
};
|
};
|
||||||
oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
oauth = getOauthInfoFromAccount(selected.account);
|
||||||
upstream = await dispatchSelectedRequest();
|
upstream = await dispatchSelectedRequest();
|
||||||
contentType = upstream.headers.get('content-type') || 'application/json';
|
contentType = upstream.headers.get('content-type') || 'application/json';
|
||||||
} catch {
|
} catch {
|
||||||
@@ -758,7 +758,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody,
|
forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
providerHeaders: buildOauthProviderHeaders({
|
providerHeaders: buildOauthProviderHeaders({
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
account: selected.account,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,114 +1,4 @@
|
|||||||
import type { ProxyResourceOwner } from '../../middleware/auth.js';
|
export {
|
||||||
import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js';
|
inlineLocalInputFileReferences,
|
||||||
import {
|
ProxyInputFileResolutionError,
|
||||||
inferInputFileMimeType,
|
} from '../../services/proxyInputFileResolver.js';
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { reportProxyAllFailed } from '../../services/alertService.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 { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
|
||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import {
|
import {
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
@@ -16,14 +10,9 @@ import {
|
|||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.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 { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.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 { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
|
||||||
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||||
import {
|
import {
|
||||||
ProxyInputFileResolutionError,
|
ProxyInputFileResolutionError,
|
||||||
@@ -32,9 +21,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildOauthProviderHeaders,
|
buildOauthProviderHeaders,
|
||||||
} from '../../services/oauth/service.js';
|
} from '../../services/oauth/service.js';
|
||||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from '../../services/oauth/oauthAccount.js';
|
||||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
|
||||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
|
||||||
import {
|
import {
|
||||||
collectResponsesFinalPayloadFromSse,
|
collectResponsesFinalPayloadFromSse,
|
||||||
collectResponsesFinalPayloadFromSseText,
|
collectResponsesFinalPayloadFromSseText,
|
||||||
@@ -50,8 +37,14 @@ import {
|
|||||||
summarizeConversationFileInputsInOpenAiBody,
|
summarizeConversationFileInputsInOpenAiBody,
|
||||||
summarizeConversationFileInputsInResponsesBody,
|
summarizeConversationFileInputsInResponsesBody,
|
||||||
} from '../capabilities/conversationFileCapabilities.js';
|
} from '../capabilities/conversationFileCapabilities.js';
|
||||||
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||||
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
import {
|
||||||
|
createSurfaceFailureToolkit,
|
||||||
|
createSurfaceDispatchRequest,
|
||||||
|
recordSurfaceSuccess,
|
||||||
|
selectSurfaceChannelForAttempt,
|
||||||
|
trySurfaceOauthRefreshRecovery,
|
||||||
|
} from './sharedSurface.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -185,18 +178,23 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
|
const failureToolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'responses',
|
||||||
|
downstreamPath,
|
||||||
|
maxRetries: MAX_RETRIES,
|
||||||
|
clientContext,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
while (retryCount <= MAX_RETRIES) {
|
while (retryCount <= MAX_RETRIES) {
|
||||||
let selected = retryCount === 0
|
const selected = await selectSurfaceChannelForAttempt({
|
||||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
requestedModel,
|
||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
downstreamPolicy,
|
||||||
|
excludeChannelIds,
|
||||||
if (!selected && retryCount === 0) {
|
retryCount,
|
||||||
await refreshModelsAndRebuildRoutes();
|
});
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
await reportProxyAllFailed({
|
await reportProxyAllFailed({
|
||||||
@@ -211,7 +209,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
excludeChannelIds.push(selected.channel.id);
|
excludeChannelIds.push(selected.channel.id);
|
||||||
|
|
||||||
const modelName = selected.actualModel || requestedModel;
|
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 isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||||
const owner = getProxyResourceOwner(request);
|
const owner = getProxyResourceOwner(request);
|
||||||
let normalizedResponsesBody: Record<string, unknown> = {
|
let normalizedResponsesBody: Record<string, unknown> = {
|
||||||
@@ -274,7 +272,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
};
|
};
|
||||||
const buildProviderHeaders = () => (
|
const buildProviderHeaders = () => (
|
||||||
buildOauthProviderHeaders({
|
buildOauthProviderHeaders({
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
account: selected.account,
|
||||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -308,19 +306,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
runtime: endpointRequest.runtime,
|
runtime: endpointRequest.runtime,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig);
|
const dispatchRequest = createSurfaceDispatchRequest({
|
||||||
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
|
site: selected.site,
|
||||||
dispatchRuntimeRequest({
|
accountExtraConfig: selected.account.extraConfig,
|
||||||
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 endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||||
isStream: isStream || isCodexSite,
|
isStream: isStream || isCodexSite,
|
||||||
requiresNativeResponsesFileUrl,
|
requiresNativeResponsesFileUrl,
|
||||||
@@ -333,28 +322,15 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
response: ctx.response,
|
response: ctx.response,
|
||||||
rawErrText: ctx.rawErrText || '',
|
rawErrText: ctx.rawErrText || '',
|
||||||
})) {
|
})) {
|
||||||
try {
|
const recovered = await trySurfaceOauthRefreshRecovery({
|
||||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
ctx,
|
||||||
selected.tokenValue = refreshed.accessToken;
|
selected,
|
||||||
selected.account = {
|
siteUrl: selected.site.url,
|
||||||
...selected.account,
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
accessToken: refreshed.accessToken,
|
dispatchRequest,
|
||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
});
|
||||||
};
|
if (recovered?.upstream?.ok) {
|
||||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
return recovered;
|
||||||
const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path);
|
|
||||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
|
||||||
if (refreshedResponse.ok) {
|
|
||||||
return {
|
|
||||||
upstream: refreshedResponse,
|
|
||||||
upstreamPath: refreshedRequest.path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
ctx.request = refreshedRequest;
|
|
||||||
ctx.response = refreshedResponse;
|
|
||||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
|
||||||
} catch {
|
|
||||||
return endpointStrategy.tryRecover(ctx);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return endpointStrategy.tryRecover(ctx);
|
return endpointStrategy.tryRecover(ctx);
|
||||||
@@ -385,136 +361,57 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
},
|
},
|
||||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||||
onDowngrade: (ctx) => {
|
onDowngrade: (ctx) => {
|
||||||
logProxy(
|
return failureToolkit.log({
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
modelRequested: requestedModel,
|
||||||
'failed',
|
status: 'failed',
|
||||||
ctx.response.status,
|
httpStatus: ctx.response.status,
|
||||||
Date.now() - startTime,
|
latencyMs: Date.now() - startTime,
|
||||||
ctx.errText,
|
errorMessage: ctx.errText,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
});
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!endpointResult.ok) {
|
if (!endpointResult.ok) {
|
||||||
const status = endpointResult.status || 502;
|
const failureOutcome = await failureToolkit.handleUpstreamFailure({
|
||||||
const errText = endpointResult.errText || 'unknown error';
|
|
||||||
const rawErrText = endpointResult.rawErrText || errText;
|
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
|
||||||
status,
|
|
||||||
errorText: rawErrText,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
status,
|
status: endpointResult.status || 502,
|
||||||
Date.now() - startTime,
|
errText: endpointResult.errText || 'unknown error',
|
||||||
errText,
|
rawErrText: endpointResult.rawErrText,
|
||||||
|
latencyMs: Date.now() - startTime,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
await recordOauthQuotaResetHint({
|
|
||||||
accountId: selected.account.id,
|
|
||||||
statusCode: status,
|
|
||||||
errorText: rawErrText,
|
|
||||||
});
|
});
|
||||||
|
if (failureOutcome.action === 'retry') {
|
||||||
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) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
await reportProxyAllFailed({
|
|
||||||
model: requestedModel,
|
|
||||||
reason: `upstream returned HTTP ${status}`,
|
|
||||||
});
|
|
||||||
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = endpointResult.upstream;
|
const upstream = endpointResult.upstream;
|
||||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||||
const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number) => {
|
const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number) => {
|
||||||
let usageForLog = {
|
|
||||||
promptTokens: parsedUsage.promptTokens,
|
|
||||||
completionTokens: parsedUsage.completionTokens,
|
|
||||||
totalTokens: parsedUsage.totalTokens,
|
|
||||||
};
|
|
||||||
let estimatedCost = 0;
|
|
||||||
let billingDetails: unknown = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
await recordSurfaceSuccess({
|
||||||
site: selected.site,
|
selected,
|
||||||
account: selected.account,
|
requestedModel,
|
||||||
tokenValue: selected.tokenValue,
|
modelName,
|
||||||
tokenName: selected.tokenName,
|
parsedUsage,
|
||||||
modelName: selected.actualModel || requestedModel,
|
|
||||||
requestStartedAtMs: startTime,
|
requestStartedAtMs: startTime,
|
||||||
requestEndedAtMs: startTime + latency,
|
latencyMs: latency,
|
||||||
localLatencyMs: latency,
|
retryCount,
|
||||||
usage: {
|
upstreamPath: successfulUpstreamPath,
|
||||||
promptTokens: parsedUsage.promptTokens,
|
logSuccess: failureToolkit.log,
|
||||||
completionTokens: parsedUsage.completionTokens,
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
totalTokens: parsedUsage.totalTokens,
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
},
|
||||||
|
bestEffortMetrics: {
|
||||||
|
errorLabel: '[responses] post-stream bookkeeping failed:',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
usageForLog = {
|
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
};
|
|
||||||
const billing = await resolveProxyLogBilling({
|
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
modelName: selected.actualModel || requestedModel,
|
|
||||||
parsedUsage,
|
|
||||||
resolvedUsage,
|
|
||||||
});
|
|
||||||
estimatedCost = billing.estimatedCost;
|
|
||||||
billingDetails = billing.billingDetails;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[responses] post-stream bookkeeping failed:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
logProxy(
|
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
|
||||||
usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails,
|
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[responses] post-stream success logging failed:', error);
|
console.error('[responses] post-stream success logging failed:', error);
|
||||||
}
|
}
|
||||||
@@ -569,25 +466,18 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
);
|
);
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id, modelName);
|
await failureToolkit.recordStreamFailure({
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
200,
|
errorMessage: streamResult.errorMessage,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
streamResult.errorMessage,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
});
|
||||||
null,
|
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,68 +498,41 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||||
status: failure.status,
|
|
||||||
errorText: failure.reason,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
failure.status,
|
failure,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
failure.reason,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
});
|
||||||
null,
|
if (failureOutcome.action === 'retry') {
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
await reportProxyAllFailed({
|
|
||||||
model: requestedModel,
|
|
||||||
reason: failure.reason,
|
|
||||||
});
|
|
||||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startSseResponse();
|
startSseResponse();
|
||||||
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
await failureToolkit.recordStreamFailure({
|
||||||
status: 502,
|
|
||||||
errorText: streamResult.errorMessage,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
200,
|
errorMessage: streamResult.errorMessage,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
streamResult.errorMessage,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
runtimeFailureStatus: 502,
|
||||||
null,
|
});
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,29 +570,19 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
await failureToolkit.recordStreamFailure({
|
||||||
status: 502,
|
|
||||||
errorText: streamResult.errorMessage,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
200,
|
errorMessage: streamResult.errorMessage,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
streamResult.errorMessage,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
runtimeFailureStatus: 502,
|
||||||
null,
|
});
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -775,39 +628,23 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
const failureOutcome = await failureToolkit.handleDetectedFailure({
|
||||||
status: failure.status,
|
|
||||||
errorText: failure.reason,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
failure.status,
|
failure,
|
||||||
latency,
|
latencyMs: latency,
|
||||||
failure.reason,
|
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
promptTokens: parsedUsage.promptTokens,
|
||||||
parsedUsage.promptTokens,
|
completionTokens: parsedUsage.completionTokens,
|
||||||
parsedUsage.completionTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
parsedUsage.totalTokens,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
});
|
||||||
null,
|
if (failureOutcome.action === 'retry') {
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
await reportProxyAllFailed({
|
|
||||||
model: requestedModel,
|
|
||||||
reason: failure.reason,
|
|
||||||
});
|
|
||||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
|
||||||
}
|
}
|
||||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||||
upstreamData,
|
upstreamData,
|
||||||
@@ -820,131 +657,42 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
usage: parsedUsage,
|
usage: parsedUsage,
|
||||||
serializationMode: isCompactRequest ? 'compact' : 'response',
|
serializationMode: isCompactRequest ? 'compact' : 'response',
|
||||||
});
|
});
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
try {
|
||||||
site: selected.site,
|
await recordSurfaceSuccess({
|
||||||
account: selected.account,
|
|
||||||
tokenValue: selected.tokenValue,
|
|
||||||
tokenName: selected.tokenName,
|
|
||||||
modelName: selected.actualModel || requestedModel,
|
|
||||||
requestStartedAtMs: startTime,
|
|
||||||
requestEndedAtMs: startTime + latency,
|
|
||||||
localLatencyMs: latency,
|
|
||||||
usage: {
|
|
||||||
promptTokens: parsedUsage.promptTokens,
|
|
||||||
completionTokens: parsedUsage.completionTokens,
|
|
||||||
totalTokens: parsedUsage.totalTokens,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
|
||||||
site: selected.site,
|
|
||||||
account: selected.account,
|
|
||||||
modelName: selected.actualModel || requestedModel,
|
|
||||||
parsedUsage,
|
|
||||||
resolvedUsage,
|
|
||||||
});
|
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
|
||||||
logProxy(
|
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
|
||||||
successfulUpstreamPath,
|
|
||||||
clientContext,
|
|
||||||
downstreamApiKeyId,
|
|
||||||
);
|
|
||||||
return reply.send(downstreamData);
|
|
||||||
} catch (err: any) {
|
|
||||||
tokenRouter.recordFailure(selected.channel.id, {
|
|
||||||
errorText: err?.message,
|
|
||||||
modelName,
|
|
||||||
});
|
|
||||||
logProxy(
|
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
modelName,
|
||||||
0,
|
parsedUsage,
|
||||||
Date.now() - startTime,
|
requestStartedAtMs: startTime,
|
||||||
err.message,
|
latencyMs: latency,
|
||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
upstreamPath: successfulUpstreamPath,
|
||||||
0,
|
logSuccess: failureToolkit.log,
|
||||||
0,
|
recordDownstreamCost: (estimatedCost) => {
|
||||||
0,
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
0,
|
},
|
||||||
null,
|
bestEffortMetrics: {
|
||||||
null,
|
errorLabel: '[responses] post-response bookkeeping failed:',
|
||||||
clientContext,
|
},
|
||||||
downstreamApiKeyId,
|
});
|
||||||
);
|
} catch (error) {
|
||||||
if (retryCount < MAX_RETRIES) {
|
console.error('[responses] post-response success logging failed:', error);
|
||||||
|
}
|
||||||
|
return reply.send(downstreamData);
|
||||||
|
} catch (err: any) {
|
||||||
|
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
modelName,
|
||||||
|
errorMessage: err?.message || 'network failure',
|
||||||
|
latencyMs: Date.now() - startTime,
|
||||||
|
retryCount,
|
||||||
|
});
|
||||||
|
if (failureOutcome.action === 'retry') {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await reportProxyAllFailed({
|
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||||
model: requestedModel,
|
|
||||||
reason: err.message || 'network failure',
|
|
||||||
});
|
|
||||||
return reply.code(502).send({
|
|
||||||
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,913 @@
|
|||||||
|
import { afterAll, 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();
|
||||||
|
const recordSuccessMock = vi.fn();
|
||||||
|
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn();
|
||||||
|
const resolveProxyLogBillingMock = vi.fn();
|
||||||
|
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||||
|
const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
|
tokenRouter: {
|
||||||
|
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||||
|
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||||
|
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||||
|
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
||||||
|
resolveProxyUsageWithSelfLogFallback: (...args: unknown[]) => resolveProxyUsageWithSelfLogFallbackMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../routes/proxy/proxyBilling.js', () => ({
|
||||||
|
resolveProxyLogBilling: (...args: unknown[]) => resolveProxyLogBillingMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||||
|
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('selectSurfaceChannelForAttempt', () => {
|
||||||
|
afterAll(() => {
|
||||||
|
consoleWarnMock.mockRestore();
|
||||||
|
consoleErrorMock.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
recordSuccessMock.mockReset();
|
||||||
|
resolveProxyUsageWithSelfLogFallbackMock.mockReset();
|
||||||
|
resolveProxyLogBillingMock.mockReset();
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||||
|
consoleWarnMock.mockClear();
|
||||||
|
consoleErrorMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
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('logs refresh failures and still retries selection once on the first attempt', async () => {
|
||||||
|
const selected = { channel: { id: 33 } };
|
||||||
|
selectChannelMock
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce(selected);
|
||||||
|
refreshModelsAndRebuildRoutesMock.mockRejectedValueOnce(new Error('refresh failed'));
|
||||||
|
|
||||||
|
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(consoleWarnMock).toHaveBeenCalledWith(
|
||||||
|
'[proxy/surface] failed to refresh routes after empty selection',
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('keeps retryable failures on the retry path even when quota hint recording fails', async () => {
|
||||||
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
|
insertProxyLogMock.mockResolvedValue(undefined);
|
||||||
|
shouldRetryProxyRequestMock.mockReturnValue(true);
|
||||||
|
isTokenExpiredErrorMock.mockReturnValue(false);
|
||||||
|
recordOauthQuotaResetHintMock.mockRejectedValue(new Error('hint failed'));
|
||||||
|
|
||||||
|
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||||
|
const toolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'chat',
|
||||||
|
downstreamPath: '/v1/chat/completions',
|
||||||
|
maxRetries: 2,
|
||||||
|
clientContext: null,
|
||||||
|
downstreamApiKeyId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(toolkit.handleUpstreamFailure({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { name: 'Codex OAuth' },
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
status: 429,
|
||||||
|
errText: 'quota exceeded',
|
||||||
|
rawErrText: '{"error":"quota exceeded"}',
|
||||||
|
latencyMs: 1200,
|
||||||
|
retryCount: 0,
|
||||||
|
})).resolves.toEqual({ action: 'retry' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a terminal upstream error response and reports token expiration when retries stop', async () => {
|
||||||
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
|
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('returns terminal failures even when final alerting throws', async () => {
|
||||||
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
|
insertProxyLogMock.mockResolvedValue(undefined);
|
||||||
|
shouldRetryProxyRequestMock.mockReturnValue(false);
|
||||||
|
isTokenExpiredErrorMock.mockReturnValue(true);
|
||||||
|
recordOauthQuotaResetHintMock.mockResolvedValue(null);
|
||||||
|
reportTokenExpiredMock.mockRejectedValue(new Error('token alert failed'));
|
||||||
|
|
||||||
|
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||||
|
const toolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'responses',
|
||||||
|
downstreamPath: '/v1/responses',
|
||||||
|
maxRetries: 2,
|
||||||
|
clientContext: null,
|
||||||
|
downstreamApiKeyId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(toolkit.handleUpstreamFailure({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { name: 'Codex OAuth' },
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
status: 401,
|
||||||
|
errText: 'expired token',
|
||||||
|
rawErrText: 'expired token',
|
||||||
|
latencyMs: 900,
|
||||||
|
retryCount: 2,
|
||||||
|
})).resolves.toEqual({
|
||||||
|
action: 'respond',
|
||||||
|
status: 401,
|
||||||
|
payload: {
|
||||||
|
error: {
|
||||||
|
message: 'expired token',
|
||||||
|
type: 'upstream_error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles detected proxy failures through the shared failure toolkit', async () => {
|
||||||
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records stream failures with error text even without a runtime status code', async () => {
|
||||||
|
composeProxyLogMessageMock.mockReturnValue('normalized error');
|
||||||
|
formatUtcSqlDateTimeMock.mockReturnValue('2026-03-21 22:00:00');
|
||||||
|
insertProxyLogMock.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const { createSurfaceFailureToolkit } = await import('./sharedSurface.js');
|
||||||
|
const toolkit = createSurfaceFailureToolkit({
|
||||||
|
warningScope: 'responses',
|
||||||
|
downstreamPath: '/v1/responses',
|
||||||
|
maxRetries: 2,
|
||||||
|
clientContext: null,
|
||||||
|
downstreamApiKeyId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await toolkit.recordStreamFailure({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { name: 'Codex OAuth' },
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
errorMessage: 'stream exploded',
|
||||||
|
latencyMs: 450,
|
||||||
|
retryCount: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordFailureMock).toHaveBeenCalledWith(11, {
|
||||||
|
errorText: 'stream exploded',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes oauth tokens through the shared recover helper and retries the rebuilt request', async () => {
|
||||||
|
const refreshedResponse = {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: vi.fn(),
|
||||||
|
};
|
||||||
|
const selected = {
|
||||||
|
account: {
|
||||||
|
id: 33,
|
||||||
|
accessToken: 'old-access-token',
|
||||||
|
extraConfig: '{"oauth":{"refreshToken":"refresh"}}',
|
||||||
|
},
|
||||||
|
tokenValue: 'old-access-token',
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
request: {
|
||||||
|
endpoint: 'responses' as const,
|
||||||
|
path: '/v1/responses',
|
||||||
|
headers: { authorization: 'Bearer old-access-token' },
|
||||||
|
body: { model: 'gpt-5.2' },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: vi.fn(),
|
||||||
|
},
|
||||||
|
rawErrText: 'expired token',
|
||||||
|
};
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||||
|
accessToken: 'new-access-token',
|
||||||
|
extraConfig: '{"oauth":{"refreshToken":"refresh-next"}}',
|
||||||
|
});
|
||||||
|
const dispatchRequest = vi.fn().mockResolvedValue(refreshedResponse);
|
||||||
|
|
||||||
|
const { trySurfaceOauthRefreshRecovery } = await import('./sharedSurface.js');
|
||||||
|
const result = await trySurfaceOauthRefreshRecovery({
|
||||||
|
ctx,
|
||||||
|
selected,
|
||||||
|
siteUrl: 'https://upstream.example.com',
|
||||||
|
buildRequest: () => ({
|
||||||
|
endpoint: 'responses',
|
||||||
|
path: '/v1/responses',
|
||||||
|
headers: { authorization: `Bearer ${selected.tokenValue}` },
|
||||||
|
body: { model: 'gpt-5.2' },
|
||||||
|
}),
|
||||||
|
dispatchRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33);
|
||||||
|
expect(selected.tokenValue).toBe('new-access-token');
|
||||||
|
expect(selected.account.accessToken).toBe('new-access-token');
|
||||||
|
expect(selected.account.extraConfig).toBe('{"oauth":{"refreshToken":"refresh-next"}}');
|
||||||
|
expect(dispatchRequest).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
headers: { authorization: 'Bearer new-access-token' },
|
||||||
|
}), 'https://upstream.example.com/v1/responses');
|
||||||
|
expect(result).toEqual({
|
||||||
|
upstream: refreshedResponse,
|
||||||
|
upstreamPath: '/v1/responses',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the recover context with the refreshed failure response when oauth refresh retry still fails', async () => {
|
||||||
|
const refreshedResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
text: vi.fn().mockResolvedValue('account mismatch'),
|
||||||
|
};
|
||||||
|
const ctx = {
|
||||||
|
request: {
|
||||||
|
endpoint: 'responses' as const,
|
||||||
|
path: '/v1/responses',
|
||||||
|
headers: { authorization: 'Bearer old-access-token' },
|
||||||
|
body: { model: 'gpt-5.2' },
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: vi.fn(),
|
||||||
|
},
|
||||||
|
rawErrText: 'expired token',
|
||||||
|
};
|
||||||
|
const selected = {
|
||||||
|
account: {
|
||||||
|
id: 33,
|
||||||
|
accessToken: 'old-access-token',
|
||||||
|
extraConfig: '{"oauth":{"refreshToken":"refresh"}}',
|
||||||
|
},
|
||||||
|
tokenValue: 'old-access-token',
|
||||||
|
};
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||||
|
accessToken: 'new-access-token',
|
||||||
|
extraConfig: '{"oauth":{"refreshToken":"refresh-next"}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { trySurfaceOauthRefreshRecovery } = await import('./sharedSurface.js');
|
||||||
|
const result = await trySurfaceOauthRefreshRecovery({
|
||||||
|
ctx,
|
||||||
|
selected,
|
||||||
|
siteUrl: 'https://upstream.example.com',
|
||||||
|
buildRequest: () => ({
|
||||||
|
endpoint: 'responses',
|
||||||
|
path: '/v1/responses',
|
||||||
|
headers: { authorization: `Bearer ${selected.tokenValue}` },
|
||||||
|
body: { model: 'gpt-5.2' },
|
||||||
|
}),
|
||||||
|
dispatchRequest: vi.fn().mockResolvedValue(refreshedResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(ctx.request.headers).toEqual({ authorization: 'Bearer new-access-token' });
|
||||||
|
expect(ctx.response).toBe(refreshedResponse);
|
||||||
|
expect(ctx.rawErrText).toBe('account mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records shared success bookkeeping with usage fallback, billing, and success logging', async () => {
|
||||||
|
resolveProxyUsageWithSelfLogFallbackMock.mockResolvedValue({
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 8,
|
||||||
|
totalTokens: 28,
|
||||||
|
});
|
||||||
|
resolveProxyLogBillingMock.mockResolvedValue({
|
||||||
|
estimatedCost: 0.42,
|
||||||
|
billingDetails: { source: 'pricing-test' },
|
||||||
|
});
|
||||||
|
const logSuccess = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const recordDownstreamCost = vi.fn();
|
||||||
|
|
||||||
|
const { recordSurfaceSuccess } = await import('./sharedSurface.js');
|
||||||
|
const result = await recordSurfaceSuccess({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
tokenValue: 'live-token',
|
||||||
|
tokenName: 'default',
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
parsedUsage: {
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
},
|
||||||
|
requestStartedAtMs: 1000,
|
||||||
|
latencyMs: 250,
|
||||||
|
retryCount: 1,
|
||||||
|
upstreamPath: '/v1/responses',
|
||||||
|
logSuccess,
|
||||||
|
recordDownstreamCost,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveProxyUsageWithSelfLogFallbackMock).toHaveBeenCalledWith({
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
tokenValue: 'live-token',
|
||||||
|
tokenName: 'default',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
requestStartedAtMs: 1000,
|
||||||
|
requestEndedAtMs: 1250,
|
||||||
|
localLatencyMs: 250,
|
||||||
|
usage: {
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(resolveProxyLogBillingMock).toHaveBeenCalledWith({
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
parsedUsage: {
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
},
|
||||||
|
resolvedUsage: {
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 8,
|
||||||
|
totalTokens: 28,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0.42, 'upstream-model');
|
||||||
|
expect(recordDownstreamCost).toHaveBeenCalledWith(0.42);
|
||||||
|
expect(logSuccess).toHaveBeenCalledWith({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
tokenValue: 'live-token',
|
||||||
|
tokenName: 'default',
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
modelRequested: 'gpt-5.2',
|
||||||
|
status: 'success',
|
||||||
|
httpStatus: 200,
|
||||||
|
latencyMs: 250,
|
||||||
|
errorMessage: null,
|
||||||
|
retryCount: 1,
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 8,
|
||||||
|
totalTokens: 28,
|
||||||
|
estimatedCost: 0.42,
|
||||||
|
billingDetails: { source: 'pricing-test' },
|
||||||
|
upstreamPath: '/v1/responses',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
resolvedUsage: {
|
||||||
|
promptTokens: 20,
|
||||||
|
completionTokens: 8,
|
||||||
|
totalTokens: 28,
|
||||||
|
},
|
||||||
|
estimatedCost: 0.42,
|
||||||
|
billingDetails: { source: 'pricing-test' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats success metrics as best-effort when requested', async () => {
|
||||||
|
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('billing failed'));
|
||||||
|
const logSuccess = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const recordDownstreamCost = vi.fn();
|
||||||
|
|
||||||
|
const { recordSurfaceSuccess } = await import('./sharedSurface.js');
|
||||||
|
const result = await recordSurfaceSuccess({
|
||||||
|
selected: {
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
account: { id: 33, username: 'oauth-user' },
|
||||||
|
site: { id: 44, url: 'https://upstream.example.com', name: 'Codex OAuth' },
|
||||||
|
tokenValue: 'live-token',
|
||||||
|
tokenName: 'default',
|
||||||
|
actualModel: 'upstream-model',
|
||||||
|
},
|
||||||
|
requestedModel: 'gpt-5.2',
|
||||||
|
modelName: 'upstream-model',
|
||||||
|
parsedUsage: {
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
},
|
||||||
|
requestStartedAtMs: 1000,
|
||||||
|
latencyMs: 250,
|
||||||
|
retryCount: 1,
|
||||||
|
upstreamPath: '/v1/responses',
|
||||||
|
logSuccess,
|
||||||
|
recordDownstreamCost,
|
||||||
|
bestEffortMetrics: {
|
||||||
|
errorLabel: '[proxy/chat] failed to record success metrics',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorMock).toHaveBeenCalledWith(
|
||||||
|
'[proxy/chat] failed to record success metrics',
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(recordSuccessMock).toHaveBeenCalledWith(11, 250, 0, 'upstream-model');
|
||||||
|
expect(recordDownstreamCost).toHaveBeenCalledWith(0);
|
||||||
|
expect(logSuccess).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
estimatedCost: 0,
|
||||||
|
billingDetails: null,
|
||||||
|
}));
|
||||||
|
expect(result).toEqual({
|
||||||
|
resolvedUsage: {
|
||||||
|
promptTokens: 10,
|
||||||
|
completionTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
recoveredFromSelfLog: false,
|
||||||
|
estimatedCostFromQuota: 0,
|
||||||
|
selfLogBillingMeta: null,
|
||||||
|
},
|
||||||
|
estimatedCost: 0,
|
||||||
|
billingDetails: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
|
import type { SiteProxyConfigLike } from '../../services/siteProxy.js';
|
||||||
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
|
import type { DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
|
||||||
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
|
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||||
|
import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||||
|
import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
|
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||||
|
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||||
|
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||||
|
|
||||||
|
type SelectedChannel = Awaited<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;
|
||||||
|
|
||||||
|
type SurfaceOauthRefreshSelectedChannel = {
|
||||||
|
account: {
|
||||||
|
id: number;
|
||||||
|
accessToken?: string | null;
|
||||||
|
extraConfig?: string | null;
|
||||||
|
};
|
||||||
|
tokenValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurfaceOauthRefreshContext<TRequest extends BuiltEndpointRequest> = {
|
||||||
|
request: TRequest;
|
||||||
|
response: Awaited<ReturnType<typeof dispatchRuntimeRequest>>;
|
||||||
|
rawErrText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurfaceSuccessSelectedChannel = SurfaceSelectedChannel & {
|
||||||
|
account: Record<string, unknown> & {
|
||||||
|
id: number;
|
||||||
|
username?: string | null;
|
||||||
|
accessToken?: string | null;
|
||||||
|
apiToken?: string | null;
|
||||||
|
extraConfig?: string | null;
|
||||||
|
platformUserId?: number | null;
|
||||||
|
};
|
||||||
|
site: Record<string, unknown> & {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
platform: string;
|
||||||
|
apiKey?: string | null;
|
||||||
|
useSystemProxy?: boolean | null;
|
||||||
|
proxyUrl?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
tokenValue: string;
|
||||||
|
tokenName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurfaceUsageSummary = {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheCreationTokens: number;
|
||||||
|
promptTokensIncludeCache: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SurfaceResolvedUsageSummary = {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
recoveredFromSelfLog: boolean;
|
||||||
|
estimatedCostFromQuota: number;
|
||||||
|
selfLogBillingMeta: import('../../services/proxyUsageFallbackService.js').SelfLogBillingMeta | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function selectSurfaceChannelForAttempt(input: {
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[proxy/surface] failed to refresh routes after empty selection', error);
|
||||||
|
}
|
||||||
|
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 async function trySurfaceOauthRefreshRecovery<TRequest extends BuiltEndpointRequest>(input: {
|
||||||
|
ctx: SurfaceOauthRefreshContext<TRequest>;
|
||||||
|
selected: SurfaceOauthRefreshSelectedChannel;
|
||||||
|
siteUrl: string;
|
||||||
|
buildRequest: (endpoint: TRequest['endpoint']) => TRequest;
|
||||||
|
dispatchRequest: (
|
||||||
|
request: TRequest,
|
||||||
|
targetUrl: string,
|
||||||
|
) => Promise<Awaited<ReturnType<typeof dispatchRuntimeRequest>>>;
|
||||||
|
captureFailureBody?: boolean;
|
||||||
|
}): Promise<{
|
||||||
|
upstream: Awaited<ReturnType<typeof dispatchRuntimeRequest>>;
|
||||||
|
upstreamPath: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshOauthAccessTokenSingleflight(input.selected.account.id);
|
||||||
|
input.selected.tokenValue = refreshed.accessToken;
|
||||||
|
input.selected.account = {
|
||||||
|
...input.selected.account,
|
||||||
|
accessToken: refreshed.accessToken,
|
||||||
|
extraConfig: refreshed.extraConfig ?? input.selected.account.extraConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshedRequest = input.buildRequest(input.ctx.request.endpoint);
|
||||||
|
const refreshedTargetUrl = buildUpstreamUrl(input.siteUrl, refreshedRequest.path);
|
||||||
|
const refreshedResponse = await input.dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||||
|
if (refreshedResponse.ok) {
|
||||||
|
return {
|
||||||
|
upstream: refreshedResponse,
|
||||||
|
upstreamPath: refreshedRequest.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
input.ctx.request = refreshedRequest;
|
||||||
|
input.ctx.response = refreshedResponse;
|
||||||
|
if (input.captureFailureBody !== false) {
|
||||||
|
input.ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordSurfaceSuccess(input: {
|
||||||
|
selected: SurfaceSuccessSelectedChannel;
|
||||||
|
requestedModel: string;
|
||||||
|
modelName: string;
|
||||||
|
parsedUsage: SurfaceUsageSummary;
|
||||||
|
requestStartedAtMs: number;
|
||||||
|
latencyMs: number;
|
||||||
|
retryCount: number;
|
||||||
|
upstreamPath?: string | null;
|
||||||
|
logSuccess: (args: {
|
||||||
|
selected: SurfaceSelectedChannel;
|
||||||
|
modelRequested: string;
|
||||||
|
status: string;
|
||||||
|
httpStatus: number;
|
||||||
|
latencyMs: number;
|
||||||
|
errorMessage: string | null;
|
||||||
|
retryCount: number;
|
||||||
|
promptTokens?: number;
|
||||||
|
completionTokens?: number;
|
||||||
|
totalTokens?: number;
|
||||||
|
estimatedCost?: number;
|
||||||
|
billingDetails?: unknown;
|
||||||
|
upstreamPath?: string | null;
|
||||||
|
}) => Promise<void>;
|
||||||
|
recordDownstreamCost?: (estimatedCost: number) => void;
|
||||||
|
bestEffortMetrics?: {
|
||||||
|
errorLabel: string;
|
||||||
|
};
|
||||||
|
}): Promise<{
|
||||||
|
resolvedUsage: SurfaceResolvedUsageSummary;
|
||||||
|
estimatedCost: number;
|
||||||
|
billingDetails: unknown;
|
||||||
|
}> {
|
||||||
|
let resolvedUsage: SurfaceResolvedUsageSummary = {
|
||||||
|
promptTokens: input.parsedUsage.promptTokens,
|
||||||
|
completionTokens: input.parsedUsage.completionTokens,
|
||||||
|
totalTokens: input.parsedUsage.totalTokens,
|
||||||
|
recoveredFromSelfLog: false,
|
||||||
|
estimatedCostFromQuota: 0,
|
||||||
|
selfLogBillingMeta: null,
|
||||||
|
};
|
||||||
|
let estimatedCost = 0;
|
||||||
|
let billingDetails: unknown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||||
|
site: input.selected.site,
|
||||||
|
account: input.selected.account,
|
||||||
|
tokenValue: input.selected.tokenValue,
|
||||||
|
tokenName: input.selected.tokenName,
|
||||||
|
modelName: input.modelName,
|
||||||
|
requestStartedAtMs: input.requestStartedAtMs,
|
||||||
|
requestEndedAtMs: input.requestStartedAtMs + input.latencyMs,
|
||||||
|
localLatencyMs: input.latencyMs,
|
||||||
|
usage: {
|
||||||
|
promptTokens: input.parsedUsage.promptTokens,
|
||||||
|
completionTokens: input.parsedUsage.completionTokens,
|
||||||
|
totalTokens: input.parsedUsage.totalTokens,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const billing = await resolveProxyLogBilling({
|
||||||
|
site: input.selected.site,
|
||||||
|
account: input.selected.account,
|
||||||
|
modelName: input.modelName,
|
||||||
|
parsedUsage: input.parsedUsage,
|
||||||
|
resolvedUsage,
|
||||||
|
});
|
||||||
|
estimatedCost = billing.estimatedCost;
|
||||||
|
billingDetails = billing.billingDetails;
|
||||||
|
} catch (error) {
|
||||||
|
if (!input.bestEffortMetrics) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.error(input.bestEffortMetrics.errorLabel, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenRouter.recordSuccess(
|
||||||
|
input.selected.channel.id,
|
||||||
|
input.latencyMs,
|
||||||
|
estimatedCost,
|
||||||
|
input.modelName,
|
||||||
|
);
|
||||||
|
input.recordDownstreamCost?.(estimatedCost);
|
||||||
|
await input.logSuccess({
|
||||||
|
selected: input.selected,
|
||||||
|
modelRequested: input.requestedModel,
|
||||||
|
status: 'success',
|
||||||
|
httpStatus: 200,
|
||||||
|
latencyMs: input.latencyMs,
|
||||||
|
errorMessage: null,
|
||||||
|
retryCount: input.retryCount,
|
||||||
|
promptTokens: resolvedUsage.promptTokens,
|
||||||
|
completionTokens: resolvedUsage.completionTokens,
|
||||||
|
totalTokens: resolvedUsage.totalTokens,
|
||||||
|
estimatedCost,
|
||||||
|
billingDetails,
|
||||||
|
upstreamPath: input.upstreamPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolvedUsage,
|
||||||
|
estimatedCost,
|
||||||
|
billingDetails,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurfaceFailureToolkit(input: {
|
||||||
|
warningScope: SurfaceWarningScope;
|
||||||
|
downstreamPath: string;
|
||||||
|
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;
|
||||||
|
|
||||||
|
const runBestEffort = (label: string, fn: () => Promise<unknown>) => {
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(fn)
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn(`[proxy/${input.warningScope}] failed to ${label}`, error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
await 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,
|
||||||
|
});
|
||||||
|
runBestEffort('record oauth quota reset hint', () => recordOauthQuotaResetHint({
|
||||||
|
accountId: args.selected.account.id,
|
||||||
|
statusCode: args.status,
|
||||||
|
errorText: rawErrText,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (isTokenExpiredError({ status: args.status, message: args.errText })) {
|
||||||
|
runBestEffort('report token expired', () => reportTokenExpired({
|
||||||
|
accountId: args.selected.account.id,
|
||||||
|
username: args.selected.account.username,
|
||||||
|
siteName: args.selected.site.name,
|
||||||
|
detail: `HTTP ${args.status}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRetryProxyRequest(args.status, args.errText)) {
|
||||||
|
const retry = maybeRetry(args.retryCount);
|
||||||
|
if (retry) return retry;
|
||||||
|
}
|
||||||
|
|
||||||
|
runBestEffort('report proxy all failed', () => 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> {
|
||||||
|
await 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
runBestEffort('report proxy all failed', () => 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> {
|
||||||
|
await 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;
|
||||||
|
|
||||||
|
runBestEffort('report proxy all failed', () => 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') {
|
||||||
|
await tokenRouter.recordFailure(args.selected.channel.id, {
|
||||||
|
status: args.runtimeFailureStatus,
|
||||||
|
errorText: errorMessage,
|
||||||
|
modelName: args.modelName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await tokenRouter.recordFailure(args.selected.channel.id, {
|
||||||
|
errorText: errorMessage,
|
||||||
|
modelName: 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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('account token route architecture boundaries', () => {
|
||||||
|
it('keeps legacy token repair from forcing route rebuilds inline', () => {
|
||||||
|
const source = readSource('./accountTokens.ts');
|
||||||
|
expect(source).not.toContain('rebuildRoutes: true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,6 @@ import { db, schema } from '../../db/index.js';
|
|||||||
import {
|
import {
|
||||||
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
|
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
|
||||||
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||||||
ensureDefaultTokenForAccount,
|
|
||||||
isMaskedPendingAccountToken,
|
isMaskedPendingAccountToken,
|
||||||
isMaskedTokenValue,
|
isMaskedTokenValue,
|
||||||
isUsableAccountToken,
|
isUsableAccountToken,
|
||||||
@@ -14,17 +13,17 @@ import {
|
|||||||
repairDefaultToken,
|
repairDefaultToken,
|
||||||
resolveAccountTokenValueStatus,
|
resolveAccountTokenValueStatus,
|
||||||
setDefaultToken,
|
setDefaultToken,
|
||||||
syncTokensFromUpstream,
|
|
||||||
} from '../../services/accountTokenService.js';
|
} from '../../services/accountTokenService.js';
|
||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
import { withAccountProxyOverride } from '../../services/siteProxy.js';
|
import { withAccountProxyOverride } from '../../services/siteProxy.js';
|
||||||
|
import { type ModelRefreshResult } from '../../services/modelService.js';
|
||||||
import {
|
import {
|
||||||
rebuildTokenRoutesFromAvailability,
|
type CoverageBatchRebuildResult,
|
||||||
refreshModelsForAccount,
|
convergeAccountMutation,
|
||||||
type ModelRefreshResult,
|
refreshAccountCoverageBatch,
|
||||||
} from '../../services/modelService.js';
|
} from '../../services/accountMutationWorkflow.js';
|
||||||
|
|
||||||
type AccountWithSiteRow = {
|
type AccountWithSiteRow = {
|
||||||
accounts: typeof schema.accounts.$inferSelect;
|
accounts: typeof schema.accounts.$inferSelect;
|
||||||
@@ -65,9 +64,7 @@ type CoverageRefreshFailureItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem;
|
type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem;
|
||||||
type CoverageRefreshRebuildResult =
|
type CoverageRefreshRebuildResult = CoverageBatchRebuildResult;
|
||||||
| { success: true; result: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
|
|
||||||
| { success: false; error: string };
|
|
||||||
|
|
||||||
const TOKEN_SYNC_TIMEOUT_MS = 15_000;
|
const TOKEN_SYNC_TIMEOUT_MS = 15_000;
|
||||||
const SYNC_ALL_BATCH_SIZE = 3;
|
const SYNC_ALL_BATCH_SIZE = 3;
|
||||||
@@ -231,7 +228,38 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
|||||||
|
|
||||||
if (!row.accounts.accessToken) {
|
if (!row.accounts.accessToken) {
|
||||||
if (row.accounts.apiToken) {
|
if (row.accounts.apiToken) {
|
||||||
ensureDefaultTokenForAccount(accountId, row.accounts.apiToken, { name: 'default', source: 'legacy' });
|
try {
|
||||||
|
const convergence = await convergeAccountMutation({
|
||||||
|
accountId,
|
||||||
|
preferredApiToken: row.accounts.apiToken,
|
||||||
|
defaultTokenSource: 'legacy',
|
||||||
|
});
|
||||||
|
if (convergence.defaultTokenId != null) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: 'synced',
|
||||||
|
reason: 'legacy_default_token_restored',
|
||||||
|
message: 'restored local default token from legacy api token',
|
||||||
|
synced: true,
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
total: 0,
|
||||||
|
defaultTokenId: convergence.defaultTokenId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: 'failed',
|
||||||
|
reason: 'sync_error',
|
||||||
|
message: error?.message || 'sync failed',
|
||||||
|
synced: false,
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
total: 0,
|
||||||
|
defaultTokenId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -296,7 +324,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) {
|
if ((synced.maskedPending || 0) > 0) {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -390,49 +422,26 @@ async function executeSyncAllAccountTokens() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCoverageForAccounts(accountIds: number[]) {
|
async function refreshCoverageForAccounts(accountIds: number[]) {
|
||||||
const uniqueAccountIds = Array.from(new Set(
|
const result = await refreshAccountCoverageBatch({
|
||||||
accountIds.filter((id) => Number.isFinite(id) && id > 0),
|
accountIds,
|
||||||
));
|
batchSize: SYNC_ALL_BATCH_SIZE,
|
||||||
|
mapFailure: buildCoverageRefreshFailureItem,
|
||||||
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}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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;
|
return {
|
||||||
try {
|
refresh: result.refresh as CoverageRefreshItem[],
|
||||||
rebuild = {
|
rebuild: result.rebuild as CoverageRefreshRebuildResult | null,
|
||||||
success: true,
|
|
||||||
result: await rebuildTokenRoutesFromAvailability(),
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed');
|
|
||||||
rebuild = {
|
|
||||||
success: false,
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { refresh, rebuild };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCoverageRefreshFailureItem(
|
function buildCoverageRefreshFailureItem(
|
||||||
|
|||||||
@@ -316,4 +316,37 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
|||||||
};
|
};
|
||||||
expect(parsedCleared.sub2apiAuth).toBeUndefined();
|
expect(parsedCleared.sub2apiAuth).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not refresh models for pin-only account edits', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'Pinned Site',
|
||||||
|
url: 'https://pinned.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'pinned-user',
|
||||||
|
accessToken: 'access-token',
|
||||||
|
status: 'active',
|
||||||
|
isPinned: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/accounts/${account.id}`,
|
||||||
|
payload: {
|
||||||
|
isPinned: true,
|
||||||
|
sortOrder: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(getModelsMock).not.toHaveBeenCalled();
|
||||||
|
expect(verifyTokenMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const updated = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||||
|
expect(updated?.isPinned).toBe(true);
|
||||||
|
expect(updated?.sortOrder).toBe(5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { db, schema, runtimeDbDialect } from '../../db/index.js';
|
|||||||
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
import { refreshBalance } from '../../services/balanceService.js';
|
import { refreshBalance } from '../../services/balanceService.js';
|
||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
|
import {
|
||||||
import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js';
|
convergeAccountMutation,
|
||||||
|
rebuildRoutesBestEffort,
|
||||||
|
} from '../../services/accountMutationWorkflow.js';
|
||||||
import {
|
import {
|
||||||
getCredentialModeFromExtraConfig,
|
getCredentialModeFromExtraConfig,
|
||||||
getProxyUrlFromExtraConfig,
|
getProxyUrlFromExtraConfig,
|
||||||
@@ -153,37 +155,29 @@ async function initializeAccountInBackground({
|
|||||||
rebuiltRoutes: false,
|
rebuiltRoutes: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tokenType === 'session' && apiToken) {
|
let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = [];
|
||||||
try {
|
|
||||||
await ensureDefaultTokenForAccount(accountId, apiToken, { name: 'default', source: 'manual' });
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tokenType === 'session' && accessToken) {
|
if (tokenType === 'session' && accessToken) {
|
||||||
try {
|
try {
|
||||||
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
|
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
|
||||||
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
|
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
|
||||||
if (summary.syncedTokenCount > 0) {
|
fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : [];
|
||||||
await syncTokensFromUpstream(accountId, syncedTokens);
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenType === 'session') {
|
const convergence = await convergeAccountMutation({
|
||||||
try {
|
accountId,
|
||||||
await refreshBalance(accountId);
|
preferredApiToken: tokenType === 'session' ? apiToken : null,
|
||||||
summary.refreshedBalance = true;
|
defaultTokenSource: 'manual',
|
||||||
} catch {}
|
ensurePreferredTokenBeforeSync: tokenType === 'session',
|
||||||
}
|
upstreamTokens: fetchedUpstreamTokens,
|
||||||
|
refreshBalance: tokenType === 'session',
|
||||||
if (skipModelFetch !== true) {
|
refreshModels: skipModelFetch !== true,
|
||||||
try {
|
rebuildRoutes: skipModelFetch !== true,
|
||||||
await refreshModelsForAccount(accountId);
|
continueOnError: true,
|
||||||
summary.refreshedModels = true;
|
});
|
||||||
await rebuildTokenRoutesFromAvailability();
|
summary.refreshedBalance = convergence.refreshedBalance;
|
||||||
summary.rebuiltRoutes = true;
|
summary.refreshedModels = convergence.refreshedModels;
|
||||||
} catch {}
|
summary.rebuiltRoutes = convergence.rebuiltRoutes;
|
||||||
}
|
|
||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
@@ -612,22 +606,16 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
return { success: false, message: 'account create failed' };
|
return { success: false, message: 'account create failed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiTokens.length > 0) {
|
await convergeAccountMutation({
|
||||||
try {
|
accountId: result.id,
|
||||||
await syncTokensFromUpstream(result.id, apiTokens);
|
preferredApiToken,
|
||||||
} catch { }
|
defaultTokenSource: 'sync',
|
||||||
} else if (preferredApiToken) {
|
upstreamTokens: apiTokens,
|
||||||
try {
|
refreshBalance: true,
|
||||||
await ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' });
|
refreshModels: true,
|
||||||
} catch { }
|
rebuildRoutes: true,
|
||||||
}
|
continueOnError: true,
|
||||||
|
});
|
||||||
// Auto-refresh balance
|
|
||||||
try { await refreshBalance(result.id); } catch { }
|
|
||||||
try {
|
|
||||||
await refreshModelsForAccount(result.id);
|
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
||||||
return {
|
return {
|
||||||
@@ -1043,19 +1031,15 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
|
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
|
||||||
|
|
||||||
if (nextApiToken) {
|
await convergeAccountMutation({
|
||||||
try {
|
accountId,
|
||||||
await ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' });
|
preferredApiToken: nextApiToken,
|
||||||
} catch {}
|
defaultTokenSource: 'sync',
|
||||||
}
|
refreshBalance: true,
|
||||||
|
refreshModels: true,
|
||||||
try {
|
rebuildRoutes: true,
|
||||||
await refreshBalance(accountId);
|
continueOnError: true,
|
||||||
} catch {}
|
});
|
||||||
try {
|
|
||||||
await refreshModelsForAccount(accountId);
|
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
|
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
|
||||||
return {
|
return {
|
||||||
@@ -1331,17 +1315,21 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
explicitNextMode && explicitNextMode !== 'auto'
|
explicitNextMode && explicitNextMode !== 'auto'
|
||||||
? explicitNextMode
|
? explicitNextMode
|
||||||
: (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey');
|
: (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey');
|
||||||
|
const needsModelRefresh =
|
||||||
|
Object.prototype.hasOwnProperty.call(body, 'accessToken')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, 'apiToken')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, 'extraConfig')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(body, 'proxyUrl')
|
||||||
|
|| wantsManagedSub2ApiAuthPatch;
|
||||||
|
|
||||||
if (nextCredentialMode !== 'apikey' && typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
await convergeAccountMutation({
|
||||||
try {
|
accountId: id,
|
||||||
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
|
preferredApiToken: nextCredentialMode !== 'apikey' ? updates.apiToken : null,
|
||||||
} catch { }
|
defaultTokenSource: 'manual',
|
||||||
}
|
refreshModels: needsModelRefresh,
|
||||||
|
rebuildRoutes: true,
|
||||||
try {
|
continueOnError: true,
|
||||||
await refreshModelsForAccount(id);
|
});
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
|
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
|
||||||
});
|
});
|
||||||
@@ -1350,9 +1338,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
|
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
|
||||||
const id = parseInt(request.params.id);
|
const id = parseInt(request.params.id);
|
||||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
|
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
|
||||||
try {
|
await rebuildRoutesBestEffort();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch { }
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1406,9 +1392,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRebuildRoutes) {
|
if (shouldRebuildRoutes) {
|
||||||
try {
|
await rebuildRoutesBestEffort();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1616,10 +1600,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
await rebuildRoutesBestEffort();
|
||||||
try {
|
|
||||||
await rebuildTokenRoutesFromAvailability();
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -243,11 +243,11 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
|
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
|
||||||
expect(parsed.oauth).toMatchObject({
|
expect(parsed.oauth).toMatchObject({
|
||||||
provider: 'antigravity',
|
|
||||||
email: 'antigravity-user@example.com',
|
email: 'antigravity-user@example.com',
|
||||||
projectId: 'antigravity-auto-project',
|
|
||||||
refreshToken: 'antigravity-refresh-token',
|
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[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
|
||||||
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
|
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({
|
expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({
|
||||||
credentialMode: 'session',
|
credentialMode: 'session',
|
||||||
oauth: {
|
oauth: {
|
||||||
provider: 'codex',
|
|
||||||
accountId: 'chatgpt-account-123',
|
|
||||||
email: 'codex-user@example.com',
|
email: 'codex-user@example.com',
|
||||||
planType: 'plus',
|
planType: 'plus',
|
||||||
refreshToken: 'oauth-refresh-token',
|
refreshToken: 'oauth-refresh-token',
|
||||||
idToken: jwt,
|
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 models = await db.select().from(schema.modelAvailability).all();
|
||||||
const modelNames = models.map((row) => row.modelName);
|
const modelNames = models.map((row) => row.modelName);
|
||||||
@@ -932,11 +933,11 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
|
|
||||||
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
|
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
|
||||||
expect(parsed.oauth).toMatchObject({
|
expect(parsed.oauth).toMatchObject({
|
||||||
provider: 'gemini-cli',
|
|
||||||
email: 'gemini-user@example.com',
|
email: 'gemini-user@example.com',
|
||||||
projectId: 'first-project-id',
|
|
||||||
refreshToken: 'gemini-refresh-token',
|
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[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects');
|
||||||
expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
|
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 || '{}');
|
const parsed = JSON.parse(accounts[0]?.extraConfig || '{}');
|
||||||
expect(parsed.oauth).toMatchObject({
|
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[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist');
|
||||||
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
|
expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser');
|
||||||
@@ -1525,18 +1527,79 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
items: expect.arrayContaining([
|
items: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
email: 'team-user@example.com',
|
|
||||||
accountKey: 'chatgpt-team-account-a',
|
accountKey: 'chatgpt-team-account-a',
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
provider: 'codex',
|
provider: 'codex',
|
||||||
email: 'team-user@example.com',
|
|
||||||
accountKey: 'chatgpt-team-account-b',
|
accountKey: 'chatgpt-team-account-b',
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backfills structured oauth identity columns for legacy oauth rows before listing connections', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'Legacy Codex',
|
||||||
|
url: 'https://codex.example.com',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
useSystemProxy: false,
|
||||||
|
isPinned: false,
|
||||||
|
globalWeight: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'legacy-user@example.com',
|
||||||
|
accessToken: 'legacy-oauth-access-token',
|
||||||
|
status: 'active',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountKey: 'legacy-chatgpt-account',
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
refreshToken: 'legacy-refresh-token',
|
||||||
|
modelDiscoveryStatus: 'healthy',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
isPinned: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
expect(account.oauthProvider).toBeNull();
|
||||||
|
expect(account.oauthAccountKey).toBeNull();
|
||||||
|
expect(account.oauthProjectId).toBeNull();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/oauth/connections',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
total: 1,
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: 'codex',
|
||||||
|
accountKey: 'legacy-chatgpt-account',
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
username: 'legacy-user@example.com',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const backfilled = await db.select().from(schema.accounts)
|
||||||
|
.where(eq(schema.accounts.id, account.id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
expect(backfilled).toEqual(expect.objectContaining({
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'legacy-chatgpt-account',
|
||||||
|
oauthProjectId: 'legacy-project',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects malformed manual callback submissions', async () => {
|
it('rejects malformed manual callback submissions', async () => {
|
||||||
const startResponse = await app.inject({
|
const startResponse = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNoDirectModelServiceRouteRefresh(source: string): void {
|
||||||
|
expect(source).not.toMatch(/import\s*\{[^}]*\brefreshModelsAndRebuildRoutes\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m);
|
||||||
|
expect(source).not.toMatch(/import\s*\{[^}]*\brebuildTokenRoutesFromAvailability\b[^}]*\}\s*from\s*['"][^'"]*modelService\.js['"]/m);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('route refresh workflow architecture boundaries', () => {
|
||||||
|
it('keeps api controllers on the shared route refresh workflow instead of modelService', () => {
|
||||||
|
const tokensSource = readSource('./tokens.ts');
|
||||||
|
const settingsSource = readSource('./settings.ts');
|
||||||
|
const statsSource = readSource('./stats.ts');
|
||||||
|
|
||||||
|
expect(tokensSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||||
|
expect(tokensSource).not.toContain("from '../../services/modelService.js'");
|
||||||
|
|
||||||
|
expect(settingsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||||
|
expect(settingsSource).not.toContain("from '../../services/modelService.js'");
|
||||||
|
|
||||||
|
expect(statsSource).toContain("from '../../services/routeRefreshWorkflow.js'");
|
||||||
|
expectNoDirectModelServiceRouteRefresh(statsSource);
|
||||||
|
expect(tokensSource).toContain('const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();');
|
||||||
|
expect(statsSource).toContain('const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps proxy fallback refreshes and scheduler hooks on the route refresh workflow', () => {
|
||||||
|
const completionsSource = readSource('../proxy/completions.ts');
|
||||||
|
const embeddingsSource = readSource('../proxy/embeddings.ts');
|
||||||
|
const imagesSource = readSource('../proxy/images.ts');
|
||||||
|
const modelsRouteSource = readSource('../proxy/models.ts');
|
||||||
|
const searchSource = readSource('../proxy/search.ts');
|
||||||
|
const videosSource = readSource('../proxy/videos.ts');
|
||||||
|
const schedulerSource = readSource('../../services/checkinScheduler.ts');
|
||||||
|
const oauthServiceSource = readSource('../../services/oauth/service.ts');
|
||||||
|
const sharedSurfaceSource = readSource('../../proxy-core/surfaces/sharedSurface.ts');
|
||||||
|
const geminiSurfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
||||||
|
|
||||||
|
for (const source of [
|
||||||
|
completionsSource,
|
||||||
|
embeddingsSource,
|
||||||
|
imagesSource,
|
||||||
|
modelsRouteSource,
|
||||||
|
searchSource,
|
||||||
|
videosSource,
|
||||||
|
schedulerSource,
|
||||||
|
oauthServiceSource,
|
||||||
|
sharedSurfaceSource,
|
||||||
|
geminiSurfaceSource,
|
||||||
|
]) {
|
||||||
|
expect(source).toContain('routeRefreshWorkflow');
|
||||||
|
expectNoDirectModelServiceRouteRefresh(source);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { fetch } from 'undici';
|
|||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { getAllBrandNames } from '../../services/brandMatcher.js';
|
import { getAllBrandNames } from '../../services/brandMatcher.js';
|
||||||
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
||||||
import { sendNotification } from '../../services/notifyService.js';
|
import { sendNotification } from '../../services/notifyService.js';
|
||||||
@@ -411,7 +411,7 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
|||||||
title: '品牌屏蔽变更后重建路由',
|
title: '品牌屏蔽变更后重建路由',
|
||||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||||
},
|
},
|
||||||
async () => refreshModelsAndRebuildRoutes(),
|
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -996,7 +996,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
title: '品牌屏蔽变更后重建路由',
|
title: '品牌屏蔽变更后重建路由',
|
||||||
dedupeKey: 'refresh-models-and-rebuild-routes',
|
dedupeKey: 'refresh-models-and-rebuild-routes',
|
||||||
},
|
},
|
||||||
async () => refreshModelsAndRebuildRoutes(),
|
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1459,7 +1459,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
failureMessage: (currentTask) => `缓存清理后重建失败:${currentTask.error || 'unknown error'}`,
|
failureMessage: (currentTask) => `缓存清理后重建失败:${currentTask.error || 'unknown error'}`,
|
||||||
},
|
},
|
||||||
async () => refreshModelsAndRebuildRoutes(),
|
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.code(202).send({
|
return reply.code(202).send({
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
import {
|
import { refreshModelsForAccount } from '../../services/modelService.js';
|
||||||
refreshModelsForAccount,
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
refreshModelsAndRebuildRoutes,
|
|
||||||
rebuildTokenRoutesFromAvailability,
|
|
||||||
} from '../../services/modelService.js';
|
|
||||||
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
|
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
|
||||||
import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
||||||
import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js';
|
import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js';
|
||||||
@@ -847,7 +844,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
failureMessage: (currentTask) => `模型广场刷新失败:${currentTask.error || 'unknown error'}`,
|
failureMessage: (currentTask) => `模型广场刷新失败:${currentTask.error || 'unknown error'}`,
|
||||||
},
|
},
|
||||||
async () => refreshModelsAndRebuildRoutes(),
|
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||||
);
|
);
|
||||||
refreshQueued = !reused;
|
refreshQueued = !reused;
|
||||||
refreshReused = reused;
|
refreshReused = reused;
|
||||||
@@ -1390,7 +1387,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refresh = await refreshModelsForAccount(accountId);
|
const refresh = await refreshModelsForAccount(accountId);
|
||||||
const rebuild = rebuildTokenRoutesFromAvailability();
|
const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
return { success: true, refresh, rebuild };
|
return { success: true, refresh, rebuild };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
ACCOUNT_TOKEN_VALUE_STATUS_READY,
|
||||||
isUsableAccountToken,
|
isUsableAccountToken,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
parseRouteDecisionSnapshot,
|
parseRouteDecisionSnapshot,
|
||||||
saveRouteDecisionSnapshots,
|
saveRouteDecisionSnapshots,
|
||||||
} from '../../services/routeDecisionSnapshotStore.js';
|
} from '../../services/routeDecisionSnapshotStore.js';
|
||||||
|
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
|
||||||
|
|
||||||
function isExactModelPattern(modelPattern: string): boolean {
|
function isExactModelPattern(modelPattern: string): boolean {
|
||||||
const normalized = modelPattern.trim();
|
const normalized = modelPattern.trim();
|
||||||
@@ -23,14 +24,13 @@ function isExactModelPattern(modelPattern: string): boolean {
|
|||||||
return !/[\*\?]/.test(normalized);
|
return !/[\*\?]/.test(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteMode = 'pattern' | 'explicit_group';
|
|
||||||
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
|
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
|
||||||
routeMode: RouteMode;
|
routeMode: RouteMode;
|
||||||
sourceRouteIds: number[];
|
sourceRouteIds: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeRouteMode(routeMode: unknown): RouteMode {
|
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 {
|
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
|
||||||
@@ -1170,12 +1170,12 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
||||||
const body = (request.body || {}) as { refreshModels?: boolean };
|
const body = (request.body || {}) as { refreshModels?: boolean };
|
||||||
if (body.refreshModels === false) {
|
if (body.refreshModels === false) {
|
||||||
const rebuild = rebuildTokenRoutesFromAvailability();
|
const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
return { success: true, rebuild };
|
return { success: true, rebuild };
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((request.body as { wait?: boolean } | undefined)?.wait) {
|
if ((request.body as { wait?: boolean } | undefined)?.wait) {
|
||||||
const result = await refreshModelsAndRebuildRoutes();
|
const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
return { success: true, ...result };
|
return { success: true, ...result };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1192,7 +1192,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`,
|
failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`,
|
||||||
},
|
},
|
||||||
async () => refreshModelsAndRebuildRoutes(),
|
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.code(202).send({
|
return reply.code(202).send({
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ describe('proxy route architecture boundaries', () => {
|
|||||||
expect(source).not.toContain('normalizeContentText(');
|
expect(source).not.toContain('normalizeContentText(');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps codex runtime header and prompt-cache derivation inside provider profiles', () => {
|
||||||
|
const source = readSource('./upstreamEndpoint.ts');
|
||||||
|
expect(source).not.toContain('buildCodexRuntimeHeaders(');
|
||||||
|
expect(source).not.toContain('shouldInjectDerivedPromptCacheKey');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
||||||
const source = readSource('./gemini.ts');
|
const source = readSource('./gemini.ts');
|
||||||
const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
||||||
@@ -139,6 +145,24 @@ describe('proxy route architecture boundaries', () => {
|
|||||||
expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps oauth refresh recovery and success bookkeeping behind shared surface helpers', () => {
|
||||||
|
const chatSurfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts');
|
||||||
|
const responsesSurfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts');
|
||||||
|
|
||||||
|
expect(chatSurfaceSource).toContain('trySurfaceOauthRefreshRecovery(');
|
||||||
|
expect(chatSurfaceSource).toContain('recordSurfaceSuccess(');
|
||||||
|
expect(chatSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight(');
|
||||||
|
expect(chatSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback(');
|
||||||
|
expect(chatSurfaceSource).not.toContain('resolveProxyLogBilling(');
|
||||||
|
expect((chatSurfaceSource.match(/bestEffortMetrics:/g) || []).length).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
expect(responsesSurfaceSource).toContain('trySurfaceOauthRefreshRecovery(');
|
||||||
|
expect(responsesSurfaceSource).toContain('recordSurfaceSuccess(');
|
||||||
|
expect(responsesSurfaceSource).not.toContain('refreshOauthAccessTokenSingleflight(');
|
||||||
|
expect(responsesSurfaceSource).not.toContain('resolveProxyUsageWithSelfLogFallback(');
|
||||||
|
expect(responsesSurfaceSource).not.toContain('resolveProxyLogBilling(');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps canonical transformer contracts imported from the transformer boundary only', () => {
|
it('keeps canonical transformer contracts imported from the transformer boundary only', () => {
|
||||||
const chatSource = readSource('./chat.ts');
|
const chatSource = readSource('./chat.ts');
|
||||||
const responsesSource = readSource('./responses.ts');
|
const responsesSource = readSource('./responses.ts');
|
||||||
|
|||||||
@@ -644,7 +644,8 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
expect(options.headers['anthropic-beta']).toBe('code-2025-09-30');
|
expect(options.headers['anthropic-beta']).toContain('claude-code-20250219');
|
||||||
|
expect(options.headers['anthropic-beta']).toContain('code-2025-09-30');
|
||||||
expect(options.headers['x-claude-client']).toBe('claude-code');
|
expect(options.headers['x-claude-client']).toBe('claude-code');
|
||||||
|
|
||||||
const forwardedBody = JSON.parse(options.body);
|
const forwardedBody = JSON.parse(options.body);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
@@ -48,7 +48,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('downstreamClientContext architecture boundaries', () => {
|
||||||
|
it('keeps protocol-level client metadata on cli profiles instead of importing individual profile helpers', () => {
|
||||||
|
const source = readSource('./downstreamClientContext.ts');
|
||||||
|
|
||||||
|
expect(source).toContain("from '../../proxy-core/cliProfiles/registry.js'");
|
||||||
|
expect(source).toContain("from '../../proxy-core/cliProfiles/types.js'");
|
||||||
|
expect(source).not.toContain("from '../../proxy-core/cliProfiles/codexProfile.js'");
|
||||||
|
expect(source).not.toContain("from '../../proxy-core/cliProfiles/claudeCodeProfile.js'");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import { extractClaudeCodeSessionId } from '../../proxy-core/cliProfiles/claudeCodeProfile.js';
|
||||||
detectDownstreamClientContext,
|
import { isCodexResponsesSurface } from '../../proxy-core/cliProfiles/codexProfile.js';
|
||||||
extractClaudeCodeSessionId,
|
import { detectDownstreamClientContext } from './downstreamClientContext.js';
|
||||||
isCodexResponsesSurface,
|
|
||||||
} from './downstreamClientContext.js';
|
|
||||||
|
|
||||||
describe('extractClaudeCodeSessionId', () => {
|
describe('extractClaudeCodeSessionId', () => {
|
||||||
it('extracts session uuid from axonhub-compatible Claude Code user ids', () => {
|
it('extracts session uuid from axonhub-compatible Claude Code user ids', () => {
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { extractClaudeCodeSessionId as extractClaudeCodeSessionIdViaProfile } from '../../proxy-core/cliProfiles/claudeCodeProfile.js';
|
import { detectCliProfile } from '../../proxy-core/cliProfiles/registry.js';
|
||||||
import {
|
import type {
|
||||||
detectCliProfile,
|
CliProfileClientConfidence,
|
||||||
} from '../../proxy-core/cliProfiles/registry.js';
|
CliProfileId,
|
||||||
import { isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile } from '../../proxy-core/cliProfiles/codexProfile.js';
|
} from '../../proxy-core/cliProfiles/types.js';
|
||||||
import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js';
|
|
||||||
|
|
||||||
export type DownstreamClientKind = CliProfileId;
|
export type DownstreamClientKind = CliProfileId;
|
||||||
export type DownstreamClientConfidence = 'exact' | 'heuristic';
|
export type DownstreamClientConfidence = CliProfileClientConfidence;
|
||||||
|
|
||||||
export type DownstreamClientContext = {
|
export type DownstreamClientContext = {
|
||||||
clientKind: DownstreamClientKind;
|
clientKind: DownstreamClientKind;
|
||||||
@@ -37,64 +36,12 @@ type DownstreamClientFingerprintRule = {
|
|||||||
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null;
|
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DownstreamProtocolClientApp = {
|
type DownstreamResolvedClientApp = {
|
||||||
clientAppId: string;
|
clientAppId: string;
|
||||||
clientAppName: string;
|
clientAppName: string;
|
||||||
clientConfidence: DownstreamClientConfidence;
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -138,10 +85,6 @@ function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedF
|
|||||||
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected));
|
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 {
|
function normalizeClientDisplayName(value: string): string | null {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -252,7 +195,7 @@ function detectDownstreamClientFingerprint(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): DownstreamProtocolClientApp | null {
|
function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): DownstreamResolvedClientApp | null {
|
||||||
for (const value of headers['x-openai-client-user-agent'] || []) {
|
for (const value of headers['x-openai-client-user-agent'] || []) {
|
||||||
const clientAppName = parseExplicitClientSelfReportValue(value);
|
const clientAppName = parseExplicitClientSelfReportValue(value);
|
||||||
if (!clientAppName) continue;
|
if (!clientAppName) continue;
|
||||||
@@ -276,63 +219,6 @@ function detectExplicitClientSelfReport(headers: NormalizedClientHeaders): Downs
|
|||||||
return null;
|
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>;
|
|
||||||
}): DownstreamProtocolClientApp | null {
|
|
||||||
switch (input.clientKind) {
|
|
||||||
case 'claude_code':
|
|
||||||
return {
|
|
||||||
clientAppId: 'claude_code',
|
|
||||||
clientAppName: 'Claude Code',
|
|
||||||
clientConfidence: 'exact',
|
|
||||||
};
|
|
||||||
case 'gemini_cli':
|
|
||||||
return {
|
|
||||||
clientAppId: 'gemini_cli',
|
|
||||||
clientAppName: 'Gemini CLI',
|
|
||||||
clientConfidence: 'exact',
|
|
||||||
};
|
|
||||||
case 'codex': {
|
|
||||||
const headers = normalizeHeaders(input.headers);
|
|
||||||
return detectCodexOfficialClientApp(headers) || {
|
|
||||||
clientAppId: 'codex',
|
|
||||||
clientAppName: 'Codex',
|
|
||||||
clientConfidence: 'heuristic',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
|
||||||
return isCodexResponsesSurfaceViaProfile(headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractClaudeCodeSessionId(userId: string): string | null {
|
|
||||||
return extractClaudeCodeSessionIdViaProfile(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectDownstreamClientContext(input: {
|
export function detectDownstreamClientContext(input: {
|
||||||
downstreamPath: string;
|
downstreamPath: string;
|
||||||
headers?: Record<string, unknown>;
|
headers?: Record<string, unknown>;
|
||||||
@@ -342,14 +228,21 @@ export function detectDownstreamClientContext(input: {
|
|||||||
const normalizedHeaders = normalizeHeaders(input.headers);
|
const normalizedHeaders = normalizeHeaders(input.headers);
|
||||||
const explicitSelfReport = detectExplicitClientSelfReport(normalizedHeaders);
|
const explicitSelfReport = detectExplicitClientSelfReport(normalizedHeaders);
|
||||||
const fingerprint = detectDownstreamClientFingerprint(input);
|
const fingerprint = detectDownstreamClientFingerprint(input);
|
||||||
const protocolClientApp = fingerprint || explicitSelfReport ? null : detectProtocolClientApp({
|
const profileClientApp = fingerprint || explicitSelfReport
|
||||||
clientKind: detected.id,
|
? null
|
||||||
headers: input.headers,
|
: (
|
||||||
});
|
detected.clientAppId && detected.clientAppName
|
||||||
|
? {
|
||||||
|
clientAppId: detected.clientAppId,
|
||||||
|
clientAppName: detected.clientAppName,
|
||||||
|
...(detected.clientConfidence ? { clientConfidence: detected.clientConfidence } : {}),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
clientKind: detected.id,
|
clientKind: detected.id,
|
||||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
||||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
||||||
...(explicitSelfReport || fingerprint || protocolClientApp || {}),
|
...(explicitSelfReport || fingerprint || profileClientApp || {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
@@ -46,7 +46,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||||
@@ -43,7 +43,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|||||||
|
|
||||||
const getProxyFileByPublicIdForOwnerMock = vi.fn();
|
const getProxyFileByPublicIdForOwnerMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../../services/proxyFileStore.js', () => ({
|
vi.mock('../../services/proxyFileStore.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../services/proxyFileStore.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args),
|
getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('inlineLocalInputFileReferences', () => {
|
describe('inlineLocalInputFileReferences', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parseProxyLogMetadata, type ParsedProxyLogMetadata } from '../../../shared/proxyLogMeta.js';
|
||||||
|
|
||||||
type ComposeProxyLogMessageArgs = {
|
type ComposeProxyLogMessageArgs = {
|
||||||
clientKind?: string | null;
|
clientKind?: string | null;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
@@ -7,30 +9,10 @@ type ComposeProxyLogMessageArgs = {
|
|||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ParsedProxyLogMessageMeta = {
|
export type ParsedProxyLogMessageMeta = ParsedProxyLogMetadata;
|
||||||
clientKind: string | null;
|
|
||||||
sessionId: string | null;
|
|
||||||
downstreamPath: string | null;
|
|
||||||
upstreamPath: string | null;
|
|
||||||
messageText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
|
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
|
||||||
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
return parseProxyLogMetadata(rawMessage);
|
||||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
|
||||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
|
||||||
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
|
|
||||||
const messageText = rawMessage.replace(
|
|
||||||
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
|
|
||||||
'',
|
|
||||||
).trim();
|
|
||||||
return {
|
|
||||||
clientKind: clientMatch?.[1]?.trim() || null,
|
|
||||||
sessionId: sessionMatch?.[1]?.trim() || null,
|
|
||||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
|
||||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
|
||||||
messageText,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function composeProxyLogMessage({
|
export function composeProxyLogMessage({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { listModelsSurface } from '../../proxy-core/surfaces/modelsSurface.js';
|
import { listModelsSurface } from '../../proxy-core/surfaces/modelsSurface.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||||
@@ -14,7 +14,7 @@ export async function modelsProxyRoute(app: FastifyInstance) {
|
|||||||
downstreamPolicy,
|
downstreamPolicy,
|
||||||
responseFormat: wantsClaudeFormat ? 'claude' : 'openai',
|
responseFormat: wantsClaudeFormat ? 'claude' : 'openai',
|
||||||
tokenRouter,
|
tokenRouter,
|
||||||
refreshModelsAndRebuildRoutes,
|
refreshModelsAndRebuildRoutes: routeRefreshWorkflow.refreshModelsAndRebuildRoutes,
|
||||||
isModelAllowed: isModelAllowedByPolicyOrAllowedRoutes,
|
isModelAllowed: isModelAllowedByPolicyOrAllowedRoutes,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -562,9 +562,7 @@ async function handleResponsesWebsocketConnection(
|
|||||||
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
|
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
|
||||||
};
|
};
|
||||||
const providerHeaders = buildOauthProviderHeaders({
|
const providerHeaders = buildOauthProviderHeaders({
|
||||||
extraConfig: typeof codexWebsocketChannel.account.extraConfig === 'string'
|
account: codexWebsocketChannel.account,
|
||||||
? codexWebsocketChannel.account.extraConfig
|
|
||||||
: null,
|
|
||||||
downstreamHeaders,
|
downstreamHeaders,
|
||||||
});
|
});
|
||||||
const prepared = buildUpstreamEndpointRequest({
|
const prepared = buildUpstreamEndpointRequest({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
@@ -71,7 +71,7 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
rankConversationFileEndpoints,
|
rankConversationFileEndpoints,
|
||||||
type ConversationFileInputSummary,
|
type ConversationFileInputSummary,
|
||||||
@@ -27,6 +27,11 @@ import {
|
|||||||
promoteResponsesCandidateAfterLegacyChatError,
|
promoteResponsesCandidateAfterLegacyChatError,
|
||||||
shouldPreferResponsesAfterLegacyChatError,
|
shouldPreferResponsesAfterLegacyChatError,
|
||||||
} from '../../transformers/shared/endpointCompatibility.js';
|
} from '../../transformers/shared/endpointCompatibility.js';
|
||||||
|
import {
|
||||||
|
buildClaudeRuntimeHeaders,
|
||||||
|
buildGeminiCliUserAgent,
|
||||||
|
headerValueToString,
|
||||||
|
} from '../../proxy-core/providers/headerUtils.js';
|
||||||
export {
|
export {
|
||||||
buildMinimalJsonHeadersForCompatibility,
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
isEndpointDispatchDeniedError,
|
isEndpointDispatchDeniedError,
|
||||||
@@ -104,23 +109,6 @@ function isClaudeFamilyModel(modelName: string): boolean {
|
|||||||
return normalized === 'claude' || normalized.startsWith('claude-') || normalized.includes('claude');
|
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([
|
const HOP_BY_HOP_HEADERS = new Set([
|
||||||
'connection',
|
'connection',
|
||||||
'keep-alive',
|
'keep-alive',
|
||||||
@@ -146,11 +134,7 @@ const BLOCKED_PASSTHROUGH_HEADERS = new Set([
|
|||||||
'sec-websocket-extensions',
|
'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 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 {
|
function shouldSkipPassthroughHeader(key: string): boolean {
|
||||||
return HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key);
|
return HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key);
|
||||||
@@ -222,81 +206,6 @@ function extractResponsesPassthroughHeaders(
|
|||||||
return forwarded;
|
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>): {
|
function extractClaudeBetasFromBody(body: Record<string, unknown>): {
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
betas: string[];
|
betas: string[];
|
||||||
@@ -327,80 +236,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: {
|
function buildAntigravityRuntimeHeaders(input: {
|
||||||
baseHeaders: Record<string, string>;
|
baseHeaders: Record<string, string>;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
@@ -414,47 +249,6 @@ function buildAntigravityRuntimeHeaders(input: {
|
|||||||
return headers;
|
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(
|
function ensureStreamAcceptHeader(
|
||||||
headers: Record<string, string>,
|
headers: Record<string, string>,
|
||||||
stream: boolean,
|
stream: boolean,
|
||||||
@@ -1437,7 +1231,10 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
);
|
);
|
||||||
const configuredResponsesBody = applyConfiguredPayloadRules(body);
|
const configuredResponsesBody = applyConfiguredPayloadRules(body);
|
||||||
|
|
||||||
if (providerProfile?.id === 'codex') {
|
if (sitePlatform === 'codex') {
|
||||||
|
if (providerProfile?.id !== 'codex') {
|
||||||
|
throw new Error(`missing codex provider profile for platform: ${sitePlatform}`);
|
||||||
|
}
|
||||||
return providerProfile.prepareRequest({
|
return providerProfile.prepareRequest({
|
||||||
endpoint: 'responses',
|
endpoint: 'responses',
|
||||||
modelName: input.modelName,
|
modelName: input.modelName,
|
||||||
@@ -1457,39 +1254,14 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = sitePlatform === 'codex'
|
const headers = ensureStreamAcceptHeader({
|
||||||
? buildCodexRuntimeHeaders({
|
|
||||||
baseHeaders: {
|
|
||||||
...commonHeaders,
|
|
||||||
...responsesHeaders,
|
|
||||||
},
|
|
||||||
providerHeaders: input.providerHeaders,
|
|
||||||
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
|
||||||
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
|
|
||||||
})
|
|
||||||
: ensureStreamAcceptHeader({
|
|
||||||
...commonHeaders,
|
...commonHeaders,
|
||||||
...responsesHeaders,
|
...responsesHeaders,
|
||||||
}, input.stream);
|
}, input.stream);
|
||||||
const codexSessionId = sitePlatform === 'codex'
|
|
||||||
? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'))
|
|
||||||
: null;
|
|
||||||
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
|
|
||||||
&& !!codexSessionId
|
|
||||||
&& !asTrimmedString((configuredResponsesBody as Record<string, unknown>).prompt_cache_key)
|
|
||||||
&& !asTrimmedString(input.codexExplicitSessionId)
|
|
||||||
&& !!asTrimmedString(input.codexSessionCacheKey);
|
|
||||||
const runtimeBody = shouldInjectDerivedPromptCacheKey
|
|
||||||
? {
|
|
||||||
...configuredResponsesBody,
|
|
||||||
prompt_cache_key: codexSessionId,
|
|
||||||
}
|
|
||||||
: configuredResponsesBody;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: resolveEndpointPath('responses'),
|
path: resolveEndpointPath('responses'),
|
||||||
headers,
|
headers,
|
||||||
body: runtimeBody,
|
body: configuredResponsesBody,
|
||||||
runtime,
|
runtime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||||
@@ -57,7 +57,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
|||||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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('only marks refreshedModels when the refresh result explicitly says it refreshed', async () => {
|
||||||
|
refreshModelsForAccountMock.mockResolvedValue({ accountId: 4, refreshed: false, status: 'skipped' });
|
||||||
|
|
||||||
|
const { convergeAccountMutation } = await import('./accountMutationWorkflow.js');
|
||||||
|
const result = await convergeAccountMutation({
|
||||||
|
accountId: 4,
|
||||||
|
refreshModels: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.modelRefreshResult).toEqual({ accountId: 4, refreshed: false, status: 'skipped' });
|
||||||
|
expect(result.refreshedModels).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes model coverage in batches and maps failures', async () => {
|
||||||
|
refreshModelsForAccountMock
|
||||||
|
.mockResolvedValueOnce({ accountId: 1, refreshed: true, status: 'success' })
|
||||||
|
.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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid batch sizes before starting the batch loop', async () => {
|
||||||
|
const { refreshAccountCoverageBatch } = await import('./accountMutationWorkflow.js');
|
||||||
|
|
||||||
|
await expect(refreshAccountCoverageBatch({
|
||||||
|
accountIds: [1, 2],
|
||||||
|
batchSize: 1.5,
|
||||||
|
mapFailure: (accountId, errorMessage) => ({ accountId, errorMessage }),
|
||||||
|
})).rejects.toThrow('batchSize must be a positive integer');
|
||||||
|
|
||||||
|
expect(refreshModelsForAccountMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a best-effort route rebuild helper for controller callers', async () => {
|
||||||
|
rebuildTokenRoutesFromAvailabilityMock.mockResolvedValueOnce({ createdRoutes: 1 });
|
||||||
|
|
||||||
|
const { rebuildRoutesBestEffort } = await import('./accountMutationWorkflow.js');
|
||||||
|
await expect(rebuildRoutesBestEffort()).resolves.toBe(true);
|
||||||
|
|
||||||
|
rebuildTokenRoutesFromAvailabilityMock.mockRejectedValueOnce(new Error('rebuild failed'));
|
||||||
|
await expect(rebuildRoutesBestEffort()).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { refreshBalance } from './balanceService.js';
|
||||||
|
import {
|
||||||
|
ensureDefaultTokenForAccount,
|
||||||
|
syncTokensFromUpstream,
|
||||||
|
} from './accountTokenService.js';
|
||||||
|
import {
|
||||||
|
refreshModelsForAccount,
|
||||||
|
type ModelRefreshResult,
|
||||||
|
} from './modelService.js';
|
||||||
|
import * as routeRefreshWorkflow from './routeRefreshWorkflow.js';
|
||||||
|
|
||||||
|
type UpstreamTokenLike = {
|
||||||
|
name?: string | null;
|
||||||
|
key?: string | null;
|
||||||
|
enabled?: boolean | null;
|
||||||
|
tokenGroup?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CoverageBatchRebuildResult =
|
||||||
|
| { success: true; result: Awaited<ReturnType<typeof routeRefreshWorkflow.rebuildRoutesOnly>> }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
export async function rebuildRoutesBestEffort(): Promise<boolean> {
|
||||||
|
return routeRefreshWorkflow.rebuildRoutesBestEffort();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 routeRefreshWorkflow.rebuildRoutesOnly>> | 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 routeRefreshWorkflow.rebuildRoutesOnly>> | 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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 = modelRefreshResult.refreshed === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.rebuildRoutes) {
|
||||||
|
const rebuildResult = await runStep(() => routeRefreshWorkflow.rebuildRoutesOnly());
|
||||||
|
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;
|
||||||
|
}> {
|
||||||
|
if (!Number.isInteger(input.batchSize) || input.batchSize <= 0) {
|
||||||
|
throw new Error('batchSize must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = input.batchSize;
|
||||||
|
const uniqueAccountIds = Array.from(new Set(
|
||||||
|
input.accountIds.filter((id) => Number.isFinite(id) && id > 0),
|
||||||
|
));
|
||||||
|
|
||||||
|
if (uniqueAccountIds.length === 0) {
|
||||||
|
return { refresh: [], rebuild: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh: Array<ModelRefreshResult | TFailure> = [];
|
||||||
|
for (let offset = 0; offset < uniqueAccountIds.length; offset += batchSize) {
|
||||||
|
const batch = uniqueAccountIds.slice(offset, offset + batchSize);
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
batch.map(async (accountId) => refreshModelsForAccount(accountId)),
|
||||||
|
);
|
||||||
|
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 routeRefreshWorkflow.rebuildRoutesOnly(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed');
|
||||||
|
return {
|
||||||
|
refresh,
|
||||||
|
rebuild: {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ const TASK_CLEANUP_INTERVAL_MS = 60 * 1000;
|
|||||||
|
|
||||||
const tasks = new Map<string, BackgroundTask>();
|
const tasks = new Map<string, BackgroundTask>();
|
||||||
const dedupeTaskIds = new Map<string, string>();
|
const dedupeTaskIds = new Map<string, string>();
|
||||||
|
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -157,13 +158,17 @@ function cleanupExpiredTasks() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS);
|
function ensureCleanupTimer() {
|
||||||
|
if (cleanupTimer) return;
|
||||||
|
cleanupTimer = setInterval(cleanupExpiredTasks, TASK_CLEANUP_INTERVAL_MS);
|
||||||
cleanupTimer.unref?.();
|
cleanupTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
export function startBackgroundTask(
|
export function startBackgroundTask(
|
||||||
options: BackgroundTaskStartOptions,
|
options: BackgroundTaskStartOptions,
|
||||||
runner: () => Promise<unknown>,
|
runner: () => Promise<unknown>,
|
||||||
): { task: BackgroundTask; reused: boolean } {
|
): { task: BackgroundTask; reused: boolean } {
|
||||||
|
ensureCleanupTimer();
|
||||||
const dedupeKey = options.dedupeKey?.trim() || '';
|
const dedupeKey = options.dedupeKey?.trim() || '';
|
||||||
if (dedupeKey) {
|
if (dedupeKey) {
|
||||||
const existingTaskId = dedupeTaskIds.get(dedupeKey);
|
const existingTaskId = dedupeTaskIds.get(dedupeKey);
|
||||||
@@ -241,4 +246,8 @@ export function summarizeCheckinResults(results: Array<{ result?: any }>): { tot
|
|||||||
export function __resetBackgroundTasksForTests() {
|
export function __resetBackgroundTasksForTests() {
|
||||||
tasks.clear();
|
tasks.clear();
|
||||||
dedupeTaskIds.clear();
|
dedupeTaskIds.clear();
|
||||||
|
if (cleanupTimer) {
|
||||||
|
clearInterval(cleanupTimer);
|
||||||
|
cleanupTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import cron from 'node-cron';
|
|||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { upsertSetting } from '../db/upsertSetting.js';
|
import { upsertSetting } from '../db/upsertSetting.js';
|
||||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||||
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||||
|
import { PLATFORM_ALIASES, detectPlatformByUrlHint } from '../../shared/platformIdentity.js';
|
||||||
|
|
||||||
const BACKUP_VERSION = '2.1';
|
const BACKUP_VERSION = '2.1';
|
||||||
|
|
||||||
@@ -244,38 +245,6 @@ const DIRECT_API_PLATFORMS = new Set([
|
|||||||
'antigravity',
|
'antigravity',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const IMPORT_PLATFORM_ALIASES: Record<string, string> = {
|
|
||||||
anyrouter: 'anyrouter',
|
|
||||||
'wong-gongyi': 'new-api',
|
|
||||||
'vo-api': 'new-api',
|
|
||||||
'super-api': 'new-api',
|
|
||||||
'rix-api': 'new-api',
|
|
||||||
'neo-api': 'new-api',
|
|
||||||
newapi: 'new-api',
|
|
||||||
'new api': 'new-api',
|
|
||||||
'new-api': 'new-api',
|
|
||||||
oneapi: 'one-api',
|
|
||||||
'one api': 'one-api',
|
|
||||||
'one-api': 'one-api',
|
|
||||||
onehub: 'one-hub',
|
|
||||||
'one-hub': 'one-hub',
|
|
||||||
donehub: 'done-hub',
|
|
||||||
'done-hub': 'done-hub',
|
|
||||||
veloera: 'veloera',
|
|
||||||
sub2api: 'sub2api',
|
|
||||||
openai: 'openai',
|
|
||||||
anthropic: 'claude',
|
|
||||||
claude: 'claude',
|
|
||||||
google: 'gemini',
|
|
||||||
gemini: 'gemini',
|
|
||||||
cliproxyapi: 'cliproxyapi',
|
|
||||||
cpa: 'cliproxyapi',
|
|
||||||
'cli-proxy-api': 'cliproxyapi',
|
|
||||||
codex: 'codex',
|
|
||||||
'gemini-cli': 'gemini-cli',
|
|
||||||
antigravity: 'antigravity',
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -317,7 +286,7 @@ function normalizeLegacyQuota(raw: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveImportedOauthColumns(row: Pick<AccountRow, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId' | 'extraConfig'>) {
|
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 oauthProvider = row.oauthProvider || oauth?.provider || null;
|
||||||
const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null;
|
const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null;
|
||||||
const oauthProjectId = row.oauthProjectId || oauth?.projectId || null;
|
const oauthProjectId = row.oauthProjectId || oauth?.projectId || null;
|
||||||
@@ -645,38 +614,18 @@ function normalizeOriginUrl(raw: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectLocalPlatformByUrlHint(url: string): string | undefined {
|
|
||||||
const normalized = url.trim().toLowerCase();
|
|
||||||
if (!normalized) return undefined;
|
|
||||||
|
|
||||||
if (normalized.includes('api.openai.com')) return 'openai';
|
|
||||||
if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex';
|
|
||||||
if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude';
|
|
||||||
if (
|
|
||||||
normalized.includes('generativelanguage.googleapis.com')
|
|
||||||
|| normalized.includes('googleapis.com/v1beta/openai')
|
|
||||||
|| normalized.includes('gemini.google.com')
|
|
||||||
) {
|
|
||||||
return 'gemini';
|
|
||||||
}
|
|
||||||
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
|
|
||||||
if (normalized.includes('anyrouter')) return 'anyrouter';
|
|
||||||
if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub';
|
|
||||||
if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub';
|
|
||||||
if (normalized.includes('veloera')) return 'veloera';
|
|
||||||
if (normalized.includes('sub2api')) return 'sub2api';
|
|
||||||
if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi';
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string {
|
function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string {
|
||||||
const normalizedPlatform = typeof rawPlatform === 'string'
|
const rawPlatformText = asString(rawPlatform).toLowerCase();
|
||||||
? IMPORT_PLATFORM_ALIASES[rawPlatform.trim().toLowerCase()]
|
const normalizedPlatform = rawPlatformText
|
||||||
: undefined;
|
? (
|
||||||
|
Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, rawPlatformText)
|
||||||
|
? PLATFORM_ALIASES[rawPlatformText]
|
||||||
|
: (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '')
|
||||||
|
)
|
||||||
|
: '';
|
||||||
if (normalizedPlatform) return normalizedPlatform;
|
if (normalizedPlatform) return normalizedPlatform;
|
||||||
|
|
||||||
const urlHint = detectLocalPlatformByUrlHint(rawUrl);
|
const urlHint = detectPlatformByUrlHint(rawUrl);
|
||||||
if (urlHint) return urlHint;
|
if (urlHint) return urlHint;
|
||||||
|
|
||||||
return normalizeLegacyPlatform(asString(rawPlatform));
|
return normalizeLegacyPlatform(asString(rawPlatform));
|
||||||
@@ -688,9 +637,9 @@ function resolveImportedProfilePlatform(apiType: unknown, baseUrl: string): stri
|
|||||||
if (normalizedType === 'anthropic') return 'claude';
|
if (normalizedType === 'anthropic') return 'claude';
|
||||||
if (normalizedType === 'google') return 'gemini';
|
if (normalizedType === 'google') return 'gemini';
|
||||||
if (normalizedType === 'openai-compatible') {
|
if (normalizedType === 'openai-compatible') {
|
||||||
return detectLocalPlatformByUrlHint(baseUrl) || 'openai';
|
return detectPlatformByUrlHint(baseUrl) || 'openai';
|
||||||
}
|
}
|
||||||
return detectLocalPlatformByUrlHint(baseUrl) || 'openai';
|
return detectPlatformByUrlHint(baseUrl) || 'openai';
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushDefaultImportedToken(
|
function pushDefaultImportedToken(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { config } from '../config.js';
|
|||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { refreshAllBalances } from './balanceService.js';
|
import { refreshAllBalances } from './balanceService.js';
|
||||||
import { checkinAll } from './checkinService.js';
|
import { checkinAll } from './checkinService.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from './modelService.js';
|
import * as routeRefreshWorkflow from './routeRefreshWorkflow.js';
|
||||||
import { sendNotification } from './notifyService.js';
|
import { sendNotification } from './notifyService.js';
|
||||||
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
||||||
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
|
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
|
||||||
@@ -162,7 +162,7 @@ function createBalanceTask(cronExpr: string) {
|
|||||||
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
|
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
|
||||||
try {
|
try {
|
||||||
await refreshAllBalances();
|
await refreshAllBalances();
|
||||||
await refreshModelsAndRebuildRoutes();
|
await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||||
console.log('[Scheduler] Balance refresh complete');
|
console.log('[Scheduler] Balance refresh complete');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Scheduler] Balance refresh error:', err);
|
console.error('[Scheduler] Balance refresh error:', err);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function readSource(relativePath: string): string {
|
||||||
|
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('modelService discovery architecture boundaries', () => {
|
||||||
|
it('keeps platform-specific oauth discovery HTTP logic in a dedicated discovery registry', () => {
|
||||||
|
const source = readSource('./modelService.ts');
|
||||||
|
|
||||||
|
expect(source).toContain("from './platformDiscoveryRegistry.js'");
|
||||||
|
expect(source).not.toContain('function discoverCodexModelsFromCloud');
|
||||||
|
expect(source).not.toContain('function discoverClaudeModelsFromCloud');
|
||||||
|
expect(source).not.toContain('function validateGeminiCliOauthConnection');
|
||||||
|
expect(source).not.toContain('function discoverAntigravityModelsFromCloud');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -757,9 +757,9 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
.get();
|
.get();
|
||||||
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
||||||
expect(parsed.oauth).toMatchObject({
|
expect(parsed.oauth).toMatchObject({
|
||||||
provider: 'codex',
|
|
||||||
modelDiscoveryStatus: 'abnormal',
|
modelDiscoveryStatus: 'abnormal',
|
||||||
});
|
});
|
||||||
|
expect(parsed.oauth).not.toHaveProperty('provider');
|
||||||
expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403');
|
expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403');
|
||||||
expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||||
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
|
expect(parsed.runtimeHealth?.state).toBe('unhealthy');
|
||||||
@@ -990,12 +990,12 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
});
|
});
|
||||||
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
const parsed = JSON.parse(latest!.extraConfig || '{}');
|
||||||
expect(parsed.oauth).toMatchObject({
|
expect(parsed.oauth).toMatchObject({
|
||||||
provider: 'gemini-cli',
|
|
||||||
email: 'gemini-refreshed-user@example.com',
|
email: 'gemini-refreshed-user@example.com',
|
||||||
refreshToken: 'gemini-refresh-token-next',
|
refreshToken: 'gemini-refresh-token-next',
|
||||||
projectId: 'project-refresh-demo',
|
|
||||||
modelDiscoveryStatus: 'healthy',
|
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 () => {
|
it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => {
|
||||||
@@ -1084,4 +1084,65 @@ describe('refreshModelsForAccount credential discovery', () => {
|
|||||||
'gemini-3-pro-preview',
|
'gemini-3-pro-preview',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('continues antigravity discovery after fetch errors and trims the oauth project id before posting', async () => {
|
||||||
|
getApiTokenMock.mockResolvedValue(null);
|
||||||
|
getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
|
||||||
|
undiciFetchMock
|
||||||
|
.mockRejectedValueOnce(new Error('network boom'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
models: {
|
||||||
|
'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'antigravity-site-trimmed',
|
||||||
|
url: 'https://cloudcode-pa.googleapis.com',
|
||||||
|
platform: 'antigravity',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'antigravity-trimmed@example.com',
|
||||||
|
accessToken: 'antigravity-access-token',
|
||||||
|
apiToken: null,
|
||||||
|
status: 'active',
|
||||||
|
oauthProvider: 'antigravity',
|
||||||
|
oauthAccountKey: 'antigravity-trimmed@example.com',
|
||||||
|
oauthProjectId: ' project-demo ',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'antigravity',
|
||||||
|
email: 'antigravity-trimmed@example.com',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const result = await refreshModelsForAccount(account.id);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
accountId: account.id,
|
||||||
|
refreshed: true,
|
||||||
|
status: 'success',
|
||||||
|
errorCode: null,
|
||||||
|
tokenScanned: 0,
|
||||||
|
discoveredByCredential: true,
|
||||||
|
discoveredApiToken: false,
|
||||||
|
modelCount: 1,
|
||||||
|
modelsPreview: ['gemini-3-pro-preview'],
|
||||||
|
});
|
||||||
|
expect(undiciFetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels');
|
||||||
|
expect(JSON.parse(String(undiciFetchMock.mock.calls[1]?.[1]?.body || '{}'))).toEqual({
|
||||||
|
project: 'project-demo',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { fetch } from 'undici';
|
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { getAdapter } from './platforms/index.js';
|
import { getAdapter } from './platforms/index.js';
|
||||||
import {
|
import {
|
||||||
@@ -22,27 +21,20 @@ import { getBlockedBrandRules, isModelBlockedByBrand } from './brandMatcher.js';
|
|||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||||
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
|
||||||
import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
|
import { withAccountProxyOverride } from './siteProxy.js';
|
||||||
import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
|
import { isCodexPlatform } from './oauth/codexAccount.js';
|
||||||
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||||
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
|
|
||||||
import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js';
|
import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js';
|
||||||
import {
|
import {
|
||||||
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
|
discoverAntigravityModelsFromCloud,
|
||||||
ANTIGRAVITY_MODELS_USER_AGENT,
|
discoverClaudeModelsFromCloud,
|
||||||
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
|
discoverCodexModelsFromCloud,
|
||||||
ANTIGRAVITY_UPSTREAM_BASE_URL,
|
validateGeminiCliOauthConnection,
|
||||||
} from './oauth/antigravityProvider.js';
|
} from './platformDiscoveryRegistry.js';
|
||||||
import {
|
|
||||||
GEMINI_CLI_GOOGLE_API_CLIENT,
|
|
||||||
GEMINI_CLI_REQUIRED_SERVICE,
|
|
||||||
GEMINI_CLI_USER_AGENT,
|
|
||||||
} from './oauth/geminiCliProvider.js';
|
|
||||||
|
|
||||||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||||
const CODEX_MODELS_CLIENT_VERSION = '1.0.0';
|
|
||||||
const GEMINI_CLI_STATIC_MODELS = [
|
const GEMINI_CLI_STATIC_MODELS = [
|
||||||
'gemini-2.5-pro',
|
'gemini-2.5-pro',
|
||||||
'gemini-2.5-flash',
|
'gemini-2.5-flash',
|
||||||
@@ -154,88 +146,6 @@ function normalizeModels(models: string[]): string[] {
|
|||||||
return normalizedModels;
|
return normalizedModels;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCodexModelsEndpoint(baseUrl: string): string {
|
|
||||||
const normalized = (baseUrl || '').replace(/\/+$/, '');
|
|
||||||
return `${normalized}/models?client_version=${encodeURIComponent(CODEX_MODELS_CLIENT_VERSION)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractCodexModelIds(payload: unknown): string[] {
|
|
||||||
const collection = (() => {
|
|
||||||
if (Array.isArray(payload)) return payload;
|
|
||||||
if (!payload || typeof payload !== 'object') return [];
|
|
||||||
const record = payload as Record<string, unknown>;
|
|
||||||
if (Array.isArray(record.models)) return record.models;
|
|
||||||
if (Array.isArray(record.data)) return record.data;
|
|
||||||
if (Array.isArray(record.items)) return record.items;
|
|
||||||
return [];
|
|
||||||
})();
|
|
||||||
|
|
||||||
return collection.flatMap((item) => {
|
|
||||||
if (typeof item === 'string') return [item];
|
|
||||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
|
|
||||||
const record = item as Record<string, unknown>;
|
|
||||||
const id = typeof record.id === 'string'
|
|
||||||
? record.id
|
|
||||||
: (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : ''));
|
|
||||||
return id ? [id] : [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractClaudeModelIds(payload: unknown): string[] {
|
|
||||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return [];
|
|
||||||
const record = payload as { data?: unknown };
|
|
||||||
if (!Array.isArray(record.data)) return [];
|
|
||||||
|
|
||||||
return record.data.flatMap((item) => {
|
|
||||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
|
|
||||||
const value = item as { id?: unknown; name?: unknown };
|
|
||||||
const id = typeof value.id === 'string'
|
|
||||||
? value.id.trim()
|
|
||||||
: (typeof value.name === 'string' ? value.name.trim() : '');
|
|
||||||
return id ? [id] : [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractAntigravityModelIds(payload: unknown): string[] {
|
|
||||||
if (!payload || typeof payload !== 'object') return [];
|
|
||||||
const record = payload as { models?: unknown };
|
|
||||||
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
|
|
||||||
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (!Array.isArray(record.models)) return [];
|
|
||||||
return record.models.flatMap((item) => {
|
|
||||||
if (typeof item === 'string') {
|
|
||||||
const trimmed = item.trim();
|
|
||||||
return trimmed ? [trimmed] : [];
|
|
||||||
}
|
|
||||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
|
|
||||||
const value = item as { id?: unknown; name?: unknown };
|
|
||||||
const id = typeof value.id === 'string'
|
|
||||||
? value.id.trim()
|
|
||||||
: (typeof value.name === 'string' ? value.name.trim() : '');
|
|
||||||
return id ? [id] : [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return [
|
|
||||||
siteUrl,
|
|
||||||
ANTIGRAVITY_UPSTREAM_BASE_URL,
|
|
||||||
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
|
|
||||||
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
|
|
||||||
].flatMap((candidate) => {
|
|
||||||
const normalized = normalizeBaseUrl(candidate);
|
|
||||||
if (!normalized || seen.has(normalized)) return [];
|
|
||||||
seen.add(normalized);
|
|
||||||
return [normalized];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateOauthModelDiscoveryState(input: {
|
async function updateOauthModelDiscoveryState(input: {
|
||||||
account: typeof schema.accounts.$inferSelect;
|
account: typeof schema.accounts.$inferSelect;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
@@ -243,10 +153,10 @@ async function updateOauthModelDiscoveryState(input: {
|
|||||||
lastModelSyncError?: string;
|
lastModelSyncError?: string;
|
||||||
lastDiscoveredModels?: string[];
|
lastDiscoveredModels?: string[];
|
||||||
}) {
|
}) {
|
||||||
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
|
const oauth = getOauthInfoFromAccount(input.account);
|
||||||
if (!oauth) return input.account.extraConfig || null;
|
if (!oauth) return input.account.extraConfig || null;
|
||||||
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
|
const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, {
|
||||||
oauth: buildOauthInfo(input.account.extraConfig, {
|
oauth: buildStoredOauthStateFromAccount(input.account, {
|
||||||
provider: oauth.provider,
|
provider: oauth.provider,
|
||||||
modelDiscoveryStatus: input.status,
|
modelDiscoveryStatus: input.status,
|
||||||
lastModelSyncAt: input.checkedAt,
|
lastModelSyncAt: input.checkedAt,
|
||||||
@@ -261,138 +171,6 @@ async function updateOauthModelDiscoveryState(input: {
|
|||||||
return extraConfig;
|
return extraConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function discoverCodexModelsFromCloud(input: {
|
|
||||||
site: typeof schema.sites.$inferSelect;
|
|
||||||
account: typeof schema.accounts.$inferSelect;
|
|
||||||
}): Promise<string[]> {
|
|
||||||
const accessToken = (input.account.accessToken || '').trim();
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error('codex oauth access token missing');
|
|
||||||
}
|
|
||||||
const oauth = getCodexOauthInfoFromExtraConfig(input.account.extraConfig);
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
Originator: 'codex_cli_rs',
|
|
||||||
};
|
|
||||||
if (oauth?.accountId) {
|
|
||||||
headers['Chatgpt-Account-Id'] = oauth.accountId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
buildCodexModelsEndpoint(input.site.url),
|
|
||||||
withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }),
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`);
|
|
||||||
}
|
|
||||||
return normalizeModels(extractCodexModelIds(await response.json()));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverClaudeModelsFromCloud(input: {
|
|
||||||
site: typeof schema.sites.$inferSelect;
|
|
||||||
account: typeof schema.accounts.$inferSelect;
|
|
||||||
}): Promise<string[]> {
|
|
||||||
const accessToken = (input.account.accessToken || '').trim();
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error('claude oauth access token missing');
|
|
||||||
}
|
|
||||||
const response = await fetch(
|
|
||||||
`${input.site.url.replace(/\/+$/, '')}/v1/models`,
|
|
||||||
withSiteRecordProxyRequestInit(input.site, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`);
|
|
||||||
}
|
|
||||||
return normalizeModels(extractClaudeModelIds(await response.json()));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateGeminiCliOauthConnection(input: {
|
|
||||||
account: typeof schema.accounts.$inferSelect;
|
|
||||||
}): Promise<void> {
|
|
||||||
const accessToken = (input.account.accessToken || '').trim();
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error('gemini cli oauth access token missing');
|
|
||||||
}
|
|
||||||
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
|
|
||||||
const projectId = (oauth?.projectId || '').trim();
|
|
||||||
if (!projectId) {
|
|
||||||
throw new Error('gemini cli oauth project id missing');
|
|
||||||
}
|
|
||||||
const response = await fetch(
|
|
||||||
`https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
|
|
||||||
withExplicitProxyRequestInit(getProxyUrlFromExtraConfig(input.account.extraConfig), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'User-Agent': GEMINI_CLI_USER_AGENT,
|
|
||||||
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
|
||||||
},
|
|
||||||
}, true),
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`);
|
|
||||||
}
|
|
||||||
const payload = await response.json() as { state?: unknown };
|
|
||||||
if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') {
|
|
||||||
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverAntigravityModelsFromCloud(input: {
|
|
||||||
site: typeof schema.sites.$inferSelect;
|
|
||||||
account: typeof schema.accounts.$inferSelect;
|
|
||||||
}): Promise<string[]> {
|
|
||||||
const accessToken = (input.account.accessToken || '').trim();
|
|
||||||
if (!accessToken) {
|
|
||||||
throw new Error('antigravity oauth access token missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig);
|
|
||||||
const requestBody = oauth?.projectId ? { project: oauth.projectId } : {};
|
|
||||||
let lastError = '';
|
|
||||||
|
|
||||||
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
|
|
||||||
const response = await fetch(
|
|
||||||
`${baseUrl}/v1internal:fetchAvailableModels`,
|
|
||||||
withSiteRecordProxyRequestInit(input.site, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
const models = normalizeModels(extractAntigravityModelIds(payload));
|
|
||||||
if (models.length > 0) {
|
|
||||||
return models;
|
|
||||||
}
|
|
||||||
lastError = '未获取到可用模型';
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(lastError || '未获取到可用模型');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExactModelPattern(modelPattern: string): boolean {
|
function isExactModelPattern(modelPattern: string): boolean {
|
||||||
const normalized = modelPattern.trim();
|
const normalized = modelPattern.trim();
|
||||||
if (!normalized) return false;
|
if (!normalized) return false;
|
||||||
@@ -510,7 +288,7 @@ export async function refreshModelsForAccount(
|
|||||||
|
|
||||||
const account = row.accounts;
|
const account = row.accounts;
|
||||||
const site = row.sites;
|
const site = row.sites;
|
||||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromAccount(account);
|
||||||
const adapter = getAdapter(site.platform);
|
const adapter = getAdapter(site.platform);
|
||||||
const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig);
|
const accountProxyUrl = getProxyUrlFromExtraConfig(account.extraConfig);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildOauthIdentityBackfillPatch,
|
||||||
|
buildOauthInfoFromAccount,
|
||||||
|
buildStoredOauthStateFromAccount,
|
||||||
|
getOauthInfoFromAccount,
|
||||||
|
isOauthProvider,
|
||||||
|
} from './oauthAccount.js';
|
||||||
|
|
||||||
|
describe('oauth account identity helpers', () => {
|
||||||
|
it('prefers structured oauth provider/accountKey/projectId columns while preserving a legacy accountId', () => {
|
||||||
|
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: 'json-user',
|
||||||
|
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('preserves legacy accountId when structured oauthAccountKey differs', () => {
|
||||||
|
const oauth = getOauthInfoFromAccount({
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'structured-account-key',
|
||||||
|
oauthProjectId: null,
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountId: 'legacy-account-id',
|
||||||
|
accountKey: 'legacy-account-key',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(oauth).toEqual(expect.objectContaining({
|
||||||
|
provider: 'codex',
|
||||||
|
accountId: 'legacy-account-id',
|
||||||
|
accountKey: 'structured-account-key',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still recognizes oauth providers from account rows after persisted state strips identity fields', () => {
|
||||||
|
const account = {
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'structured-account',
|
||||||
|
oauthProjectId: 'structured-project',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: buildStoredOauthStateFromAccount({
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'structured-account',
|
||||||
|
oauthProjectId: 'structured-project',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountId: 'legacy-account',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isOauthProvider(account)).toBe(true);
|
||||||
|
expect(isOauthProvider(account, 'codex')).toBe(true);
|
||||||
|
expect(isOauthProvider(account, 'gemini-cli')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a structured identity backfill patch from legacy oauth metadata only for missing columns', () => {
|
||||||
|
expect(buildOauthIdentityBackfillPatch({
|
||||||
|
oauthProvider: null,
|
||||||
|
oauthAccountKey: null,
|
||||||
|
oauthProjectId: null,
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountKey: 'legacy-account',
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})).toEqual({
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'legacy-account',
|
||||||
|
oauthProjectId: 'legacy-project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildOauthIdentityBackfillPatch({
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'structured-account',
|
||||||
|
oauthProjectId: null,
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
oauth: {
|
||||||
|
provider: 'legacy-provider-ignored',
|
||||||
|
accountKey: 'legacy-account-ignored',
|
||||||
|
projectId: 'legacy-project',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})).toEqual({
|
||||||
|
oauthProjectId: 'legacy-project',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,6 +43,17 @@ export type OauthInfo = {
|
|||||||
lastDiscoveredModels?: string[];
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoredOauthIdentity = Pick<OauthInfo, 'provider' | 'accountId' | 'accountKey' | 'projectId'>;
|
||||||
|
|
||||||
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
|
function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig {
|
||||||
if (!extraConfig) return {};
|
if (!extraConfig) return {};
|
||||||
try {
|
try {
|
||||||
@@ -104,19 +115,26 @@ function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | und
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
|
function parseStoredOauthIdentity(extraConfig?: string | null): StoredOauthIdentity | null {
|
||||||
const parsed = parseExtraConfig(extraConfig).oauth;
|
const parsed = parseExtraConfig(extraConfig).oauth;
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
|
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
|
||||||
const provider = asTrimmedString(parsed.provider);
|
const provider = asTrimmedString(parsed.provider);
|
||||||
if (!provider) return null;
|
if (!provider) return null;
|
||||||
const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId);
|
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
accountId: asTrimmedString(parsed.accountId) || accountKey,
|
accountId: asTrimmedString(parsed.accountId) || accountKey,
|
||||||
accountKey,
|
accountKey,
|
||||||
|
projectId: asTrimmedString(parsed.projectId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStoredOauthRuntimeState(extraConfig?: string | null): Partial<OauthInfo> | null {
|
||||||
|
const parsed = parseExtraConfig(extraConfig).oauth;
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
||||||
|
return {
|
||||||
email: asTrimmedString(parsed.email),
|
email: asTrimmedString(parsed.email),
|
||||||
planType: asTrimmedString(parsed.planType),
|
planType: asTrimmedString(parsed.planType),
|
||||||
projectId: asTrimmedString(parsed.projectId),
|
|
||||||
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
|
tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt),
|
||||||
refreshToken: asTrimmedString(parsed.refreshToken),
|
refreshToken: asTrimmedString(parsed.refreshToken),
|
||||||
idToken: asTrimmedString(parsed.idToken),
|
idToken: asTrimmedString(parsed.idToken),
|
||||||
@@ -129,6 +147,70 @@ export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthI
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null {
|
||||||
|
const identity = parseStoredOauthIdentity(extraConfig);
|
||||||
|
const runtime = parseStoredOauthRuntimeState(extraConfig);
|
||||||
|
const provider = identity?.provider;
|
||||||
|
if (!provider) return null;
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
accountId: identity?.accountId || identity?.accountKey,
|
||||||
|
accountKey: identity?.accountKey,
|
||||||
|
projectId: identity?.projectId,
|
||||||
|
email: runtime?.email,
|
||||||
|
planType: runtime?.planType,
|
||||||
|
tokenExpiresAt: runtime?.tokenExpiresAt,
|
||||||
|
refreshToken: runtime?.refreshToken,
|
||||||
|
idToken: runtime?.idToken,
|
||||||
|
providerData: runtime?.providerData,
|
||||||
|
quota: runtime?.quota,
|
||||||
|
modelDiscoveryStatus: runtime?.modelDiscoveryStatus,
|
||||||
|
lastModelSyncAt: runtime?.lastModelSyncAt,
|
||||||
|
lastModelSyncError: runtime?.lastModelSyncError,
|
||||||
|
lastDiscoveredModels: runtime?.lastDiscoveredModels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOauthInfoFromAccount(account?: OauthIdentityCarrier | null): OauthInfo | null {
|
||||||
|
if (!account) return null;
|
||||||
|
const storedIdentity = parseStoredOauthIdentity(account.extraConfig);
|
||||||
|
const storedRuntime = parseStoredOauthRuntimeState(account.extraConfig);
|
||||||
|
const provider = asTrimmedString(account.oauthProvider) || storedIdentity?.provider;
|
||||||
|
if (!provider) return null;
|
||||||
|
const structuredAccountKey = asTrimmedString(account.oauthAccountKey);
|
||||||
|
const accountKey = structuredAccountKey || storedIdentity?.accountKey || storedIdentity?.accountId;
|
||||||
|
const accountId = storedIdentity?.accountId || structuredAccountKey || accountKey;
|
||||||
|
const projectId = asTrimmedString(account.oauthProjectId) || storedIdentity?.projectId;
|
||||||
|
return {
|
||||||
|
...(storedRuntime || {}),
|
||||||
|
provider,
|
||||||
|
accountId,
|
||||||
|
accountKey,
|
||||||
|
projectId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOauthIdentityBackfillPatch(
|
||||||
|
account?: OauthIdentityCarrier | null,
|
||||||
|
): Partial<Pick<OauthIdentityCarrier, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId'>> | null {
|
||||||
|
if (!account) return null;
|
||||||
|
const legacyIdentity = parseStoredOauthIdentity(account.extraConfig);
|
||||||
|
if (!legacyIdentity?.provider) return null;
|
||||||
|
|
||||||
|
const patch: Partial<Pick<OauthIdentityCarrier, 'oauthProvider' | 'oauthAccountKey' | 'oauthProjectId'>> = {};
|
||||||
|
if (!asTrimmedString(account.oauthProvider)) {
|
||||||
|
patch.oauthProvider = legacyIdentity.provider;
|
||||||
|
}
|
||||||
|
if (!asTrimmedString(account.oauthAccountKey) && (legacyIdentity.accountKey || legacyIdentity.accountId)) {
|
||||||
|
patch.oauthAccountKey = legacyIdentity.accountKey || legacyIdentity.accountId;
|
||||||
|
}
|
||||||
|
if (!asTrimmedString(account.oauthProjectId) && legacyIdentity.projectId) {
|
||||||
|
patch.oauthProjectId = legacyIdentity.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(patch).length > 0 ? patch : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildOauthInfo(
|
export function buildOauthInfo(
|
||||||
extraConfig?: string | null,
|
extraConfig?: string | null,
|
||||||
patch: Partial<OauthInfo> = {},
|
patch: Partial<OauthInfo> = {},
|
||||||
@@ -152,14 +234,54 @@ export function buildOauthInfo(
|
|||||||
return next;
|
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(
|
export function isOauthProvider(
|
||||||
account: Pick<typeof schema.accounts.$inferSelect, 'extraConfig'> | string | null | undefined,
|
account: OauthIdentityCarrier | string | null | undefined,
|
||||||
provider?: string,
|
provider?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const extraConfig = typeof account === 'string' || account == null
|
const oauth = typeof account === 'string' || account == null
|
||||||
? account
|
? getOauthInfoFromExtraConfig(account)
|
||||||
: account.extraConfig;
|
: getOauthInfoFromAccount(account);
|
||||||
const oauth = getOauthInfoFromExtraConfig(extraConfig);
|
|
||||||
if (!oauth) return false;
|
if (!oauth) return false;
|
||||||
if (!provider) return true;
|
if (!provider) return true;
|
||||||
return oauth.provider === provider;
|
return oauth.provider === provider;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { eq, isNotNull, isNull, or } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import { buildOauthIdentityBackfillPatch } from './oauthAccount.js';
|
||||||
|
|
||||||
|
let inFlightOauthIdentityBackfill: Promise<number> | null = null;
|
||||||
|
|
||||||
|
async function runOauthIdentityBackfill(): Promise<number> {
|
||||||
|
const rows = await db.select().from(schema.accounts)
|
||||||
|
.where(or(
|
||||||
|
isNotNull(schema.accounts.extraConfig),
|
||||||
|
isNull(schema.accounts.oauthProvider),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const patch = buildOauthIdentityBackfillPatch(row);
|
||||||
|
if (!patch) continue;
|
||||||
|
await db.update(schema.accounts).set({
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).where(eq(schema.accounts.id, row.id)).run();
|
||||||
|
updated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureOauthIdentityBackfill(): Promise<number> {
|
||||||
|
if (inFlightOauthIdentityBackfill) {
|
||||||
|
return inFlightOauthIdentityBackfill;
|
||||||
|
}
|
||||||
|
|
||||||
|
inFlightOauthIdentityBackfill = (async () => {
|
||||||
|
try {
|
||||||
|
return await runOauthIdentityBackfill();
|
||||||
|
} finally {
|
||||||
|
inFlightOauthIdentityBackfill = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return inFlightOauthIdentityBackfill;
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { mergeAccountExtraConfig } from '../accountExtraConfig.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';
|
import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js';
|
||||||
|
|
||||||
type CodexJwtClaims = {
|
type CodexJwtClaims = {
|
||||||
@@ -192,12 +196,12 @@ async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnaps
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('oauth account not found');
|
throw new Error('oauth account not found');
|
||||||
}
|
}
|
||||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromAccount(account);
|
||||||
if (!oauth) {
|
if (!oauth) {
|
||||||
throw new Error('account is not managed by oauth');
|
throw new Error('account is not managed by oauth');
|
||||||
}
|
}
|
||||||
const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||||
oauth: buildOauthInfo(account.extraConfig, {
|
oauth: buildStoredOauthStateFromAccount(account, {
|
||||||
quota: snapshot,
|
quota: snapshot,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -213,7 +217,7 @@ export async function refreshOauthQuotaSnapshot(accountId: number): Promise<Oaut
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('oauth account not found');
|
throw new Error('oauth account not found');
|
||||||
}
|
}
|
||||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromAccount(account);
|
||||||
if (!oauth) {
|
if (!oauth) {
|
||||||
throw new Error('account is not managed by 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();
|
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get();
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromAccount(account);
|
||||||
if (!oauth || oauth.provider !== 'codex') return null;
|
if (!oauth || oauth.provider !== 'codex') return null;
|
||||||
|
|
||||||
const baseSnapshot = buildQuotaSnapshotFromOauthInfo({
|
const baseSnapshot = buildQuotaSnapshotFromOauthInfo({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
||||||
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js';
|
import { refreshModelsForAccount } from '../modelService.js';
|
||||||
|
import * as routeRefreshWorkflow from '../routeRefreshWorkflow.js';
|
||||||
import {
|
import {
|
||||||
createOauthSession,
|
createOauthSession,
|
||||||
getOauthSession,
|
getOauthSession,
|
||||||
@@ -14,8 +15,15 @@ import {
|
|||||||
listOAuthProviderDefinitions,
|
listOAuthProviderDefinitions,
|
||||||
type OAuthProviderDefinition,
|
type OAuthProviderDefinition,
|
||||||
} from './providers.js';
|
} from './providers.js';
|
||||||
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js';
|
import {
|
||||||
|
buildOauthInfo,
|
||||||
|
buildOauthInfoFromAccount,
|
||||||
|
buildStoredOauthState,
|
||||||
|
buildStoredOauthStateFromAccount,
|
||||||
|
getOauthInfoFromAccount,
|
||||||
|
} from './oauthAccount.js';
|
||||||
import { buildCodexOauthInfo } from './codexAccount.js';
|
import { buildCodexOauthInfo } from './codexAccount.js';
|
||||||
|
import { ensureOauthIdentityBackfill } from './oauthIdentityBackfill.js';
|
||||||
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js';
|
||||||
|
|
||||||
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
type OAuthProviderMetadata = ReturnType<typeof listOauthProviders>[number];
|
||||||
@@ -232,7 +240,7 @@ async function upsertOauthAccount(input: {
|
|||||||
});
|
});
|
||||||
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
|
const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, {
|
||||||
credentialMode: 'session',
|
credentialMode: 'session',
|
||||||
oauth,
|
oauth: buildStoredOauthState(oauth),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -398,7 +406,7 @@ export async function handleOauthCallback(input: {
|
|||||||
updatedAt: previousAccount.updatedAt,
|
updatedAt: previousAccount.updatedAt,
|
||||||
}).where(eq(schema.accounts.id, previousAccount.id)).run();
|
}).where(eq(schema.accounts.id, previousAccount.id)).run();
|
||||||
}
|
}
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
|
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
|
||||||
markOauthSessionError(input.state, errorMessage);
|
markOauthSessionError(input.state, errorMessage);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
@@ -411,7 +419,7 @@ export async function handleOauthCallback(input: {
|
|||||||
}).where(eq(schema.accounts.id, account.id)).run();
|
}).where(eq(schema.accounts.id, account.id)).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
markOauthSessionSuccess(input.state, {
|
markOauthSessionSuccess(input.state, {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
@@ -458,6 +466,7 @@ export async function listOauthConnections(options: {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
await ensureOauthIdentityBackfill();
|
||||||
const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
|
const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50)));
|
||||||
const offset = Math.max(0, Math.trunc(options.offset ?? 0));
|
const offset = Math.max(0, Math.trunc(options.offset ?? 0));
|
||||||
|
|
||||||
@@ -510,15 +519,16 @@ export async function listOauthConnections(options: {
|
|||||||
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
|
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = rows.map((row) => {
|
const items = rows.flatMap((row) => {
|
||||||
const oauth = getOauthInfoFromExtraConfig(row.accounts.extraConfig)!;
|
const oauth = getOauthInfoFromAccount(row.accounts);
|
||||||
|
if (!oauth) return [];
|
||||||
const models = modelMap.get(row.accounts.id) || [];
|
const models = modelMap.get(row.accounts.id) || [];
|
||||||
const status = (
|
const status = (
|
||||||
oauth.modelDiscoveryStatus === 'abnormal'
|
oauth.modelDiscoveryStatus === 'abnormal'
|
||||||
|| row.accounts.status !== 'active'
|
|| row.accounts.status !== 'active'
|
||||||
|| row.sites.status !== 'active'
|
|| row.sites.status !== 'active'
|
||||||
) ? 'abnormal' : 'healthy';
|
) ? 'abnormal' : 'healthy';
|
||||||
return {
|
return [{
|
||||||
accountId: row.accounts.id,
|
accountId: row.accounts.id,
|
||||||
siteId: row.sites.id,
|
siteId: row.sites.id,
|
||||||
provider: oauth.provider,
|
provider: oauth.provider,
|
||||||
@@ -540,7 +550,7 @@ export async function listOauthConnections(options: {
|
|||||||
url: row.sites.url,
|
url: row.sites.url,
|
||||||
platform: row.sites.platform,
|
platform: row.sites.platform,
|
||||||
},
|
},
|
||||||
};
|
}];
|
||||||
});
|
});
|
||||||
|
|
||||||
return { items, total, limit, offset };
|
return { items, total, limit, offset };
|
||||||
@@ -553,12 +563,12 @@ export async function deleteOauthConnection(accountId: number) {
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('oauth account not found');
|
throw new Error('oauth account not found');
|
||||||
}
|
}
|
||||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
const normalizedOauth = getOauthInfoFromAccount(account);
|
||||||
if (!oauth) {
|
if (!normalizedOauth) {
|
||||||
throw new Error('account is not managed by oauth');
|
throw new Error('account is not managed by oauth');
|
||||||
}
|
}
|
||||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run();
|
await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run();
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +584,7 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('oauth account not found');
|
throw new Error('oauth account not found');
|
||||||
}
|
}
|
||||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromAccount(account);
|
||||||
if (!oauth) {
|
if (!oauth) {
|
||||||
throw new Error('account is not managed by oauth');
|
throw new Error('account is not managed by oauth');
|
||||||
}
|
}
|
||||||
@@ -587,10 +597,18 @@ export async function startOauthRebindFlow(accountId: number, requestOrigin?: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildOauthProviderHeaders(input: {
|
export function buildOauthProviderHeaders(input: {
|
||||||
|
account?: {
|
||||||
|
extraConfig?: string | null;
|
||||||
|
oauthProvider?: string | null;
|
||||||
|
oauthAccountKey?: string | null;
|
||||||
|
oauthProjectId?: string | null;
|
||||||
|
} | null;
|
||||||
extraConfig?: string | null;
|
extraConfig?: string | null;
|
||||||
downstreamHeaders?: Record<string, unknown>;
|
downstreamHeaders?: Record<string, unknown>;
|
||||||
}) {
|
}) {
|
||||||
const oauth = getOauthInfoFromExtraConfig(input.extraConfig);
|
const oauth = getOauthInfoFromAccount(input.account || {
|
||||||
|
extraConfig: input.extraConfig,
|
||||||
|
});
|
||||||
if (!oauth) return {};
|
if (!oauth) return {};
|
||||||
const definition = getOAuthProviderDefinition(oauth.provider);
|
const definition = getOAuthProviderDefinition(oauth.provider);
|
||||||
if (!definition?.buildProxyHeaders) return {};
|
if (!definition?.buildProxyHeaders) return {};
|
||||||
@@ -619,7 +637,7 @@ export async function refreshOauthAccessToken(accountId: number) {
|
|||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('oauth account not found');
|
throw new Error('oauth account not found');
|
||||||
}
|
}
|
||||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
const oauth = getOauthInfoFromAccount(account);
|
||||||
if (!oauth?.refreshToken) {
|
if (!oauth?.refreshToken) {
|
||||||
throw new Error('oauth refresh token missing');
|
throw new Error('oauth refresh token missing');
|
||||||
}
|
}
|
||||||
@@ -635,7 +653,7 @@ export async function refreshOauthAccessToken(accountId: number) {
|
|||||||
providerData: oauth.providerData,
|
providerData: oauth.providerData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const nextOauth = buildOauthInfo(account.extraConfig, {
|
const nextOauth = buildOauthInfoFromAccount(account, {
|
||||||
provider: oauth.provider,
|
provider: oauth.provider,
|
||||||
accountId: refreshed.accountId || oauth.accountId,
|
accountId: refreshed.accountId || oauth.accountId,
|
||||||
accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId,
|
accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId,
|
||||||
@@ -652,7 +670,7 @@ export async function refreshOauthAccessToken(accountId: number) {
|
|||||||
});
|
});
|
||||||
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
const extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||||
credentialMode: 'session',
|
credentialMode: 'session',
|
||||||
oauth: nextOauth,
|
oauth: buildStoredOauthStateFromAccount(account, nextOauth),
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.update(schema.accounts).set({
|
await db.update(schema.accounts).set({
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { fetch } from 'undici';
|
||||||
|
import { schema } from '../db/index.js';
|
||||||
|
import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js';
|
||||||
|
import { withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
|
||||||
|
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||||
|
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
|
||||||
|
import {
|
||||||
|
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
|
||||||
|
ANTIGRAVITY_MODELS_USER_AGENT,
|
||||||
|
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
|
||||||
|
ANTIGRAVITY_UPSTREAM_BASE_URL,
|
||||||
|
} from './oauth/antigravityProvider.js';
|
||||||
|
import {
|
||||||
|
GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
|
GEMINI_CLI_REQUIRED_SERVICE,
|
||||||
|
GEMINI_CLI_USER_AGENT,
|
||||||
|
} from './oauth/geminiCliProvider.js';
|
||||||
|
|
||||||
|
type PlatformDiscoverySite = typeof schema.sites.$inferSelect;
|
||||||
|
type PlatformDiscoveryAccount = typeof schema.accounts.$inferSelect;
|
||||||
|
|
||||||
|
function normalizeDiscoveredModels(models: string[]): string[] {
|
||||||
|
const normalizedModels: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const rawModel of models) {
|
||||||
|
if (typeof rawModel !== 'string') continue;
|
||||||
|
const modelName = rawModel.trim();
|
||||||
|
if (!modelName) continue;
|
||||||
|
|
||||||
|
const dedupeKey = modelName.toLowerCase();
|
||||||
|
if (seen.has(dedupeKey)) continue;
|
||||||
|
seen.add(dedupeKey);
|
||||||
|
normalizedModels.push(modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return (baseUrl || '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCodexModelsEndpoint(baseUrl: string): string {
|
||||||
|
const normalized = normalizeBaseUrl(baseUrl);
|
||||||
|
return `${normalized}/models?client_version=${encodeURIComponent('1.0.0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCodexModelIds(payload: unknown): string[] {
|
||||||
|
const collection = (() => {
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (!payload || typeof payload !== 'object') return [];
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (Array.isArray(record.models)) return record.models;
|
||||||
|
if (Array.isArray(record.data)) return record.data;
|
||||||
|
if (Array.isArray(record.items)) return record.items;
|
||||||
|
return [];
|
||||||
|
})();
|
||||||
|
|
||||||
|
return collection.flatMap((item) => {
|
||||||
|
if (typeof item === 'string') return [item];
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const id = typeof record.id === 'string'
|
||||||
|
? record.id
|
||||||
|
: (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : ''));
|
||||||
|
return id ? [id] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractClaudeModelIds(payload: unknown): string[] {
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return [];
|
||||||
|
const record = payload as { data?: unknown };
|
||||||
|
if (!Array.isArray(record.data)) return [];
|
||||||
|
|
||||||
|
return record.data.flatMap((item) => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
|
||||||
|
const value = item as { id?: unknown; name?: unknown };
|
||||||
|
const id = typeof value.id === 'string'
|
||||||
|
? value.id.trim()
|
||||||
|
: (typeof value.name === 'string' ? value.name.trim() : '');
|
||||||
|
return id ? [id] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAntigravityModelIds(payload: unknown): string[] {
|
||||||
|
if (!payload || typeof payload !== 'object') return [];
|
||||||
|
const record = payload as { models?: unknown };
|
||||||
|
if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) {
|
||||||
|
return Object.keys(record.models).map((name) => name.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(record.models)) return [];
|
||||||
|
return record.models.flatMap((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
return trimmed ? [trimmed] : [];
|
||||||
|
}
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return [];
|
||||||
|
const value = item as { id?: unknown; name?: unknown };
|
||||||
|
const id = typeof value.id === 'string'
|
||||||
|
? value.id.trim()
|
||||||
|
: (typeof value.name === 'string' ? value.name.trim() : '');
|
||||||
|
return id ? [id] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return [
|
||||||
|
siteUrl,
|
||||||
|
ANTIGRAVITY_UPSTREAM_BASE_URL,
|
||||||
|
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
|
||||||
|
ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL,
|
||||||
|
].flatMap((candidate) => {
|
||||||
|
const normalized = normalizeBaseUrl(candidate);
|
||||||
|
if (!normalized || seen.has(normalized)) return [];
|
||||||
|
seen.add(normalized);
|
||||||
|
return [normalized];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverCodexModelsFromCloud(input: {
|
||||||
|
site: PlatformDiscoverySite;
|
||||||
|
account: PlatformDiscoveryAccount;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const accessToken = (input.account.accessToken || '').trim();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('codex oauth access token missing');
|
||||||
|
}
|
||||||
|
const oauth = getOauthInfoFromAccount(input.account);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
Originator: 'codex_cli_rs',
|
||||||
|
};
|
||||||
|
if (oauth?.accountId) {
|
||||||
|
headers['Chatgpt-Account-Id'] = oauth.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
buildCodexModelsEndpoint(input.site.url),
|
||||||
|
withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }),
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`);
|
||||||
|
}
|
||||||
|
return normalizeDiscoveredModels(extractCodexModelIds(await response.json()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverClaudeModelsFromCloud(input: {
|
||||||
|
site: PlatformDiscoverySite;
|
||||||
|
account: PlatformDiscoveryAccount;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const accessToken = (input.account.accessToken || '').trim();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('claude oauth access token missing');
|
||||||
|
}
|
||||||
|
const response = await fetch(
|
||||||
|
`${input.site.url.replace(/\/+$/, '')}/v1/models`,
|
||||||
|
withSiteRecordProxyRequestInit(input.site, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`);
|
||||||
|
}
|
||||||
|
return normalizeDiscoveredModels(extractClaudeModelIds(await response.json()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateGeminiCliOauthConnection(input: {
|
||||||
|
account: PlatformDiscoveryAccount;
|
||||||
|
}): Promise<void> {
|
||||||
|
const accessToken = (input.account.accessToken || '').trim();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('gemini cli oauth access token missing');
|
||||||
|
}
|
||||||
|
const oauth = getOauthInfoFromAccount(input.account);
|
||||||
|
const projectId = (oauth?.projectId || '').trim();
|
||||||
|
if (!projectId) {
|
||||||
|
throw new Error('gemini cli oauth project id missing');
|
||||||
|
}
|
||||||
|
const response = await fetch(
|
||||||
|
`https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`,
|
||||||
|
withExplicitProxyRequestInit(getProxyUrlFromExtraConfig(input.account.extraConfig), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': GEMINI_CLI_USER_AGENT,
|
||||||
|
'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT,
|
||||||
|
},
|
||||||
|
}, true),
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`);
|
||||||
|
}
|
||||||
|
const payload = await response.json() as { state?: unknown };
|
||||||
|
if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') {
|
||||||
|
throw new Error(`Cloud AI API not enabled for project ${projectId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverAntigravityModelsFromCloud(input: {
|
||||||
|
site: PlatformDiscoverySite;
|
||||||
|
account: PlatformDiscoveryAccount;
|
||||||
|
}): Promise<string[]> {
|
||||||
|
const accessToken = (input.account.accessToken || '').trim();
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('antigravity oauth access token missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauth = getOauthInfoFromAccount(input.account);
|
||||||
|
const projectId = (oauth?.projectId || '').trim();
|
||||||
|
const requestBody = projectId ? { project: projectId } : {};
|
||||||
|
let lastError = '';
|
||||||
|
|
||||||
|
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/v1internal:fetchAvailableModels`,
|
||||||
|
withSiteRecordProxyRequestInit(input.site, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload));
|
||||||
|
if (models.length > 0) {
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
lastError = '未获取到可用模型';
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? `${baseUrl}: ${error.message}` : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(lastError || '未获取到可用模型');
|
||||||
|
}
|
||||||
@@ -1,20 +1,7 @@
|
|||||||
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
import { StandardApiProviderAdapterBase } from './standardApiProvider.js';
|
||||||
|
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from '../oauth/claudeProvider.js';
|
||||||
|
|
||||||
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
|
export class ClaudeAdapter extends StandardApiProviderAdapterBase {
|
||||||
|
|
||||||
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 {
|
|
||||||
readonly platformName = 'claude';
|
readonly platformName = 'claude';
|
||||||
|
|
||||||
async detect(url: string): Promise<boolean> {
|
async detect(url: string): Promise<boolean> {
|
||||||
@@ -22,34 +9,13 @@ export class ClaudeAdapter extends BasePlatformAdapter {
|
|||||||
return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1');
|
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[]> {
|
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||||
try {
|
return this.fetchModelsFromStandardEndpoint({
|
||||||
const res = await this.fetchJson<any>(resolveClaudeModelsUrl(baseUrl), {
|
baseUrl,
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': apiToken,
|
'x-api-key': apiToken,
|
||||||
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
|
'anthropic-version': CLAUDE_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 {
|
export class CliProxyApiAdapter extends StandardApiProviderAdapterBase {
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CliProxyApiAdapter extends BasePlatformAdapter {
|
|
||||||
readonly platformName = 'cliproxyapi';
|
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> {
|
async detect(url: string): Promise<boolean> {
|
||||||
const normalized = (url || '').toLowerCase();
|
const normalized = (url || '').toLowerCase();
|
||||||
|
|
||||||
// Quick check: default CLIProxyAPI port
|
|
||||||
if (/:8317(\/|$)/.test(normalized)) {
|
if (/:8317(\/|$)/.test(normalized)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick check: common hostname keyword
|
|
||||||
if (normalized.includes('cliproxy')) {
|
if (normalized.includes('cliproxy')) {
|
||||||
return true;
|
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 {
|
try {
|
||||||
const base = normalizeBaseUrl(url);
|
const base = normalizePlatformBaseUrl(url);
|
||||||
const { fetch } = await import('undici');
|
const { fetch } = await import('undici');
|
||||||
const res = await fetch(`${base}/v0/management/openai-compatibility`, {
|
const res = await fetch(`${base}/v0/management/openai-compatibility`, {
|
||||||
method: 'GET',
|
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[]> {
|
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||||
try {
|
return this.fetchModelsFromStandardEndpoint({
|
||||||
const base = normalizeBaseUrl(baseUrl);
|
baseUrl,
|
||||||
const res = await this.fetchJson<any>(`${base}/v1/models`, {
|
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
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';
|
import { StandardApiProviderAdapterBase, normalizePlatformBaseUrl } from './standardApiProvider.js';
|
||||||
|
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripModelPrefix(name: string): string {
|
function stripModelPrefix(name: string): string {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -24,13 +20,13 @@ function isOpenAiCompatGeminiBase(baseUrl: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveGeminiOpenAiModelsUrl(baseUrl: string): string {
|
function resolveGeminiOpenAiModelsUrl(baseUrl: string): string {
|
||||||
const normalized = normalizeBaseUrl(baseUrl);
|
const normalized = normalizePlatformBaseUrl(baseUrl);
|
||||||
if (/\/models$/i.test(normalized)) return normalized;
|
if (/\/models$/i.test(normalized)) return normalized;
|
||||||
return `${normalized}/models`;
|
return `${normalized}/models`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string {
|
function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string {
|
||||||
const normalized = normalizeBaseUrl(baseUrl);
|
const normalized = normalizePlatformBaseUrl(baseUrl);
|
||||||
const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized)
|
const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized)
|
||||||
? normalized
|
? normalized
|
||||||
: `${normalized}/v1beta`;
|
: `${normalized}/v1beta`;
|
||||||
@@ -41,7 +37,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string
|
|||||||
return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`;
|
return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GeminiAdapter extends BasePlatformAdapter {
|
export class GeminiAdapter extends StandardApiProviderAdapterBase {
|
||||||
readonly platformName: string = 'gemini';
|
readonly platformName: string = 'gemini';
|
||||||
|
|
||||||
async detect(url: string): Promise<boolean> {
|
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[]> {
|
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
const normalizedBase = normalizePlatformBaseUrl(baseUrl);
|
||||||
|
|
||||||
if (isOpenAiCompatGeminiBase(normalizedBase)) {
|
if (isOpenAiCompatGeminiBase(normalizedBase)) {
|
||||||
try {
|
const openAiModels = await this.fetchModelsFromStandardEndpoint({
|
||||||
const res = await this.fetchJson<any>(resolveGeminiOpenAiModelsUrl(normalizedBase), {
|
baseUrl: normalizedBase,
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
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);
|
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -92,13 +70,11 @@ export class GeminiAdapter extends BasePlatformAdapter {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
if (!isOpenAiCompatGeminiBase(normalizedBase)) {
|
if (!isOpenAiCompatGeminiBase(normalizedBase)) {
|
||||||
try {
|
const openAiModels = await this.fetchModelsFromStandardEndpoint({
|
||||||
const res = await this.fetchJson<any>(`${normalizedBase}/v1beta/openai/models`, {
|
baseUrl: `${normalizedBase}/v1beta/openai`,
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
headers: { Authorization: `Bearer ${apiToken}` },
|
||||||
});
|
});
|
||||||
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
|
|
||||||
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
|
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GeminiCliAdapter } from './geminiCli.js';
|
|||||||
import { AntigravityAdapter } from './antigravity.js';
|
import { AntigravityAdapter } from './antigravity.js';
|
||||||
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||||
import { detectPlatformByTitle } from './titleHint.js';
|
import { detectPlatformByTitle } from './titleHint.js';
|
||||||
|
import { detectPlatformByUrlHint, normalizePlatformAlias } from '../../../shared/platformIdentity.js';
|
||||||
|
|
||||||
const adapters: PlatformAdapter[] = [
|
const adapters: PlatformAdapter[] = [
|
||||||
// Specific forks before generic adapters for better auto-detection.
|
// Specific forks before generic adapters for better auto-detection.
|
||||||
@@ -33,47 +34,8 @@ const adapters: PlatformAdapter[] = [
|
|||||||
new OneApiAdapter(),
|
new OneApiAdapter(),
|
||||||
];
|
];
|
||||||
|
|
||||||
const platformAliases: Record<string, string> = {
|
|
||||||
// NewAPI family aliases
|
|
||||||
anyrouter: 'anyrouter',
|
|
||||||
'wong-gongyi': 'new-api',
|
|
||||||
'vo-api': 'new-api',
|
|
||||||
'super-api': 'new-api',
|
|
||||||
'rix-api': 'new-api',
|
|
||||||
'neo-api': 'new-api',
|
|
||||||
newapi: 'new-api',
|
|
||||||
'new api': 'new-api',
|
|
||||||
// OneAPI family aliases
|
|
||||||
oneapi: 'one-api',
|
|
||||||
'one api': 'one-api',
|
|
||||||
// Keep canonical forms explicit for clarity
|
|
||||||
'new-api': 'new-api',
|
|
||||||
'one-api': 'one-api',
|
|
||||||
veloera: 'veloera',
|
|
||||||
'one-hub': 'one-hub',
|
|
||||||
'done-hub': 'done-hub',
|
|
||||||
sub2api: 'sub2api',
|
|
||||||
// Official upstream APIs
|
|
||||||
openai: 'openai',
|
|
||||||
codex: 'codex',
|
|
||||||
'chatgpt-codex': 'codex',
|
|
||||||
'chatgpt codex': 'codex',
|
|
||||||
anthropic: 'claude',
|
|
||||||
claude: 'claude',
|
|
||||||
gemini: 'gemini',
|
|
||||||
'gemini-cli': 'gemini-cli',
|
|
||||||
antigravity: 'antigravity',
|
|
||||||
'anti-gravity': 'antigravity',
|
|
||||||
google: 'gemini',
|
|
||||||
// CLIProxyAPI aliases
|
|
||||||
cliproxyapi: 'cliproxyapi',
|
|
||||||
cpa: 'cliproxyapi',
|
|
||||||
'cli-proxy-api': 'cliproxyapi',
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizePlatform(platform: string): string {
|
function normalizePlatform(platform: string): string {
|
||||||
const raw = (platform || '').trim().toLowerCase();
|
return normalizePlatformAlias(platform);
|
||||||
return platformAliases[raw] ?? raw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdapter(platform: string): PlatformAdapter | undefined {
|
export function getAdapter(platform: string): PlatformAdapter | undefined {
|
||||||
@@ -89,36 +51,6 @@ const titleFirstPlatforms = new Set<string>([
|
|||||||
'sub2api',
|
'sub2api',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function detectPlatformByUrlHint(url: string): string | undefined {
|
|
||||||
const normalized = (url || '').trim().toLowerCase();
|
|
||||||
if (!normalized) return undefined;
|
|
||||||
|
|
||||||
// Official upstream endpoints.
|
|
||||||
if (normalized.includes('api.openai.com')) return 'openai';
|
|
||||||
if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex';
|
|
||||||
if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude';
|
|
||||||
if (
|
|
||||||
normalized.includes('generativelanguage.googleapis.com')
|
|
||||||
|| normalized.includes('googleapis.com/v1beta/openai')
|
|
||||||
|| normalized.includes('gemini.google.com')
|
|
||||||
) {
|
|
||||||
return 'gemini';
|
|
||||||
}
|
|
||||||
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
|
|
||||||
|
|
||||||
// NewAPI-family forks and common aliases.
|
|
||||||
if (normalized.includes('anyrouter')) return 'anyrouter';
|
|
||||||
if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub';
|
|
||||||
if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub';
|
|
||||||
if (normalized.includes('veloera')) return 'veloera';
|
|
||||||
if (normalized.includes('sub2api')) return 'sub2api';
|
|
||||||
|
|
||||||
// CLIProxyAPI default local endpoints.
|
|
||||||
if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi';
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function detectPlatform(url: string): Promise<PlatformAdapter | undefined> {
|
export async function detectPlatform(url: string): Promise<PlatformAdapter | undefined> {
|
||||||
const urlHint = detectPlatformByUrlHint(url);
|
const urlHint = detectPlatformByUrlHint(url);
|
||||||
if (urlHint) {
|
if (urlHint) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AddressInfo } from 'node:net';
|
|||||||
import { OpenAiAdapter } from './openai.js';
|
import { OpenAiAdapter } from './openai.js';
|
||||||
import { ClaudeAdapter } from './claude.js';
|
import { ClaudeAdapter } from './claude.js';
|
||||||
import { GeminiAdapter } from './gemini.js';
|
import { GeminiAdapter } from './gemini.js';
|
||||||
|
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||||
|
|
||||||
interface RequestSnapshot {
|
interface RequestSnapshot {
|
||||||
method: string;
|
method: string;
|
||||||
@@ -52,6 +53,20 @@ describe('official llm upstream adapters', () => {
|
|||||||
return;
|
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')) {
|
if (req.url?.startsWith('/gemini/v1beta/models')) {
|
||||||
const url = new URL(req.url, 'http://127.0.0.1');
|
const url = new URL(req.url, 'http://127.0.0.1');
|
||||||
if (url.searchParams.get('key') !== 'gemini-key') {
|
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']);
|
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 () => {
|
it('fetches models from gemini native endpoint and normalizes model names', async () => {
|
||||||
const adapter = new GeminiAdapter();
|
const adapter = new GeminiAdapter();
|
||||||
const models = await adapter.getModels(`${baseUrl}/gemini/v1beta`, 'gemini-key');
|
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 {
|
export class OpenAiAdapter extends StandardApiProviderAdapterBase {
|
||||||
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 {
|
|
||||||
readonly platformName = 'openai';
|
readonly platformName = 'openai';
|
||||||
|
|
||||||
async detect(url: string): Promise<boolean> {
|
async detect(url: string): Promise<boolean> {
|
||||||
@@ -20,31 +8,10 @@ export class OpenAiAdapter extends BasePlatformAdapter {
|
|||||||
return normalized.includes('api.openai.com');
|
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[]> {
|
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||||
try {
|
return this.fetchModelsFromStandardEndpoint({
|
||||||
const res = await this.fetchJson<any>(resolveOpenAiModelsUrl(baseUrl), {
|
baseUrl,
|
||||||
headers: { Authorization: `Bearer ${apiToken}` },
|
headers: { Authorization: `Bearer ${apiToken}` },
|
||||||
});
|
});
|
||||||
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
StandardApiProviderAdapterBase,
|
||||||
|
normalizePlatformBaseUrl,
|
||||||
|
resolveVersionedModelsUrl,
|
||||||
|
} from './standardApiProvider.js';
|
||||||
|
|
||||||
|
class TestStandardApiProviderAdapter extends StandardApiProviderAdapterBase {
|
||||||
|
readonly platformName = 'test-standard';
|
||||||
|
fetchJsonImpl = async () => ({ data: [] as Array<{ id: string }> });
|
||||||
|
|
||||||
|
async detect(_url: string): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getModels(_baseUrl: string, _token: string): Promise<string[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async fetchJson<T>(url: string, options?: Parameters<StandardApiProviderAdapterBase['fetchJson']>[1]): Promise<T> {
|
||||||
|
return this.fetchJsonImpl(url, options) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchModelsForTest(options: Parameters<StandardApiProviderAdapterBase['fetchModelsFromStandardEndpoint']>[0]) {
|
||||||
|
return this.fetchModelsFromStandardEndpoint(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not swallow mapper bugs while still returning empty lists for network failures', async () => {
|
||||||
|
const adapter = new TestStandardApiProviderAdapter();
|
||||||
|
adapter.fetchJsonImpl = async () => ({ data: [{ id: 'gpt-5' }] });
|
||||||
|
|
||||||
|
await expect(adapter.fetchModelsForTest({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
mapResponse: () => {
|
||||||
|
throw new Error('mapper exploded');
|
||||||
|
},
|
||||||
|
})).rejects.toThrow('mapper exploded');
|
||||||
|
|
||||||
|
adapter.fetchJsonImpl = async () => {
|
||||||
|
throw new Error('network failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(adapter.fetchModelsForTest({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
})).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid payload shapes instead of silently treating them as no models', async () => {
|
||||||
|
const adapter = new TestStandardApiProviderAdapter();
|
||||||
|
adapter.fetchJsonImpl = async () => ({ data: 'not-an-array' });
|
||||||
|
|
||||||
|
await expect(adapter.fetchModelsForTest({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
})).rejects.toThrow('invalid standard models payload');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 {
|
||||||
|
let normalized = baseUrl || '';
|
||||||
|
while (normalized.endsWith('/')) {
|
||||||
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let payload: any;
|
||||||
|
try {
|
||||||
|
payload = await this.fetchJson<any>(url, {
|
||||||
|
headers: options.headers,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = options.mapResponse
|
||||||
|
? options.mapResponse(payload)
|
||||||
|
: Array.isArray(payload?.data)
|
||||||
|
? payload.data.map((item: any) => item?.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!Array.isArray(rows)) {
|
||||||
|
throw new Error('invalid standard models payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
const getProxyFileByPublicIdForOwnerMock = vi.fn();
|
const getProxyFileByPublicIdForOwnerMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('./proxyFileStore.js', () => ({
|
vi.mock('./proxyFileStore.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('./proxyFileStore.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args),
|
getProxyFileByPublicIdForOwner: (...args: unknown[]) => getProxyFileByPublicIdForOwnerMock(...args),
|
||||||
LOCAL_PROXY_FILE_ID_PREFIX: 'file-metapi-',
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
describe('proxyInputFileResolver', () => {
|
describe('proxyInputFileResolver', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -76,4 +79,93 @@ 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back from application/octet-stream to filename-based mime inference', async () => {
|
||||||
|
const { inlineLocalInputFileReferences } = await import('./proxyInputFileResolver.js');
|
||||||
|
await expect(inlineLocalInputFileReferences(
|
||||||
|
{
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'file',
|
||||||
|
file: {
|
||||||
|
filename: 'paper.pdf',
|
||||||
|
mime_type: 'application/octet-stream',
|
||||||
|
file_data: Buffer.from('%PDF-octet').toString('base64'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ ownerType: 'managed_key', ownerId: '7' },
|
||||||
|
)).resolves.toEqual({
|
||||||
|
model: 'gpt-5',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'file',
|
||||||
|
file: {
|
||||||
|
file_data: Buffer.from('%PDF-octet').toString('base64'),
|
||||||
|
filename: 'paper.pdf',
|
||||||
|
mime_type: 'application/pdf',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import type { ProxyResourceOwner } from '../middleware/auth.js';
|
|||||||
import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
|
import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
|
||||||
import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js';
|
import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js';
|
||||||
import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js';
|
import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js';
|
||||||
|
import {
|
||||||
|
isSupportedConversationFileMimeType,
|
||||||
|
resolveConversationFileMimeType,
|
||||||
|
} from '../../shared/conversationFileTypes.js';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
@@ -23,31 +27,8 @@ function cloneJsonValue<T>(value: T): T {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferMimeTypeFromFilename(filename: string): string {
|
|
||||||
const normalized = filename.toLowerCase();
|
|
||||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
|
||||||
if (normalized.endsWith('.txt')) return 'text/plain';
|
|
||||||
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
|
|
||||||
if (normalized.endsWith('.json')) return 'application/json';
|
|
||||||
if (normalized.endsWith('.png')) return 'image/png';
|
|
||||||
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
|
||||||
if (normalized.endsWith('.gif')) return 'image/gif';
|
|
||||||
if (normalized.endsWith('.webp')) return 'image/webp';
|
|
||||||
if (normalized.endsWith('.wav')) return 'audio/wav';
|
|
||||||
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
|
|
||||||
if (normalized.endsWith('.m4a')) return 'audio/mp4';
|
|
||||||
if (normalized.endsWith('.ogg')) return 'audio/ogg';
|
|
||||||
if (normalized.endsWith('.webm')) return 'audio/webm';
|
|
||||||
return 'application/octet-stream';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSupportedMimeType(mimeType: string): boolean {
|
function isSupportedMimeType(mimeType: string): boolean {
|
||||||
return mimeType === 'application/pdf'
|
return isSupportedConversationFileMimeType(mimeType);
|
||||||
|| mimeType === 'text/plain'
|
|
||||||
|| mimeType === 'text/markdown'
|
|
||||||
|| mimeType === 'application/json'
|
|
||||||
|| mimeType.startsWith('image/')
|
|
||||||
|| mimeType.startsWith('audio/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function audioFormatFromMimeType(mimeType: string, filename: string): string {
|
function audioFormatFromMimeType(mimeType: string, filename: string): string {
|
||||||
@@ -118,7 +99,7 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc
|
|||||||
}> {
|
}> {
|
||||||
if (fileLike.fileData) {
|
if (fileLike.fileData) {
|
||||||
const filename = fileLike.filename || 'upload.bin';
|
const filename = fileLike.filename || 'upload.bin';
|
||||||
const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename);
|
const mimeType = resolveConversationFileMimeType(fileLike.mimeType, filename);
|
||||||
if (!isSupportedMimeType(mimeType)) {
|
if (!isSupportedMimeType(mimeType)) {
|
||||||
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
|
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
|
||||||
}
|
}
|
||||||
@@ -135,12 +116,14 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc
|
|||||||
if (!stored) {
|
if (!stored) {
|
||||||
throw new ProxyInputFileResolutionError(404, `file not found: ${fileLike.fileId}`, 'not_found_error');
|
throw new ProxyInputFileResolutionError(404, `file not found: ${fileLike.fileId}`, 'not_found_error');
|
||||||
}
|
}
|
||||||
const mimeType = fileLike.mimeType || stored.mimeType || inferMimeTypeFromFilename(stored.filename);
|
const mimeType = resolveConversationFileMimeType(
|
||||||
|
fileLike.mimeType || stored.mimeType,
|
||||||
|
stored.filename,
|
||||||
|
);
|
||||||
if (!isSupportedMimeType(mimeType)) {
|
if (!isSupportedMimeType(mimeType)) {
|
||||||
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
|
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
fileId: fileLike.fileId,
|
|
||||||
filename: fileLike.filename || stored.filename,
|
filename: fileLike.filename || stored.filename,
|
||||||
fileData: stored.contentBase64,
|
fileData: stored.contentBase64,
|
||||||
mimeType,
|
mimeType,
|
||||||
@@ -148,7 +131,7 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = fileLike.filename || 'upload.bin';
|
const filename = fileLike.filename || 'upload.bin';
|
||||||
const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename);
|
const mimeType = resolveConversationFileMimeType(fileLike.mimeType, filename);
|
||||||
if (!fileLike.fileData) {
|
if (!fileLike.fileData) {
|
||||||
throw new ProxyInputFileResolutionError(400, `file_data is required for inline file block: ${filename}`);
|
throw new ProxyInputFileResolutionError(400, `file_data is required for inline file block: ${filename}`);
|
||||||
}
|
}
|
||||||
@@ -239,6 +222,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(
|
export async function resolveOpenAiBodyInputFiles(
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
owner: ProxyResourceOwner,
|
owner: ProxyResourceOwner,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { startBackgroundTask } from './backgroundTaskService.js';
|
||||||
|
import {
|
||||||
|
rebuildTokenRoutesFromAvailability,
|
||||||
|
refreshModelsAndRebuildRoutes as refreshModelsAndRebuildRoutesViaModelService,
|
||||||
|
} from './modelService.js';
|
||||||
|
|
||||||
|
export async function rebuildRoutesOnly() {
|
||||||
|
return rebuildTokenRoutesFromAvailability();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rebuildRoutesBestEffort() {
|
||||||
|
try {
|
||||||
|
await rebuildRoutesOnly();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshModelsAndRebuildRoutes() {
|
||||||
|
return refreshModelsAndRebuildRoutesViaModelService();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueRefreshModelsAndRebuildRoutesTask(input: {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
dedupeKey?: string;
|
||||||
|
notifyOnFailure?: boolean;
|
||||||
|
successMessage: (currentTask: { result?: unknown }) => string;
|
||||||
|
failureMessage: (currentTask: { error?: string | null }) => string;
|
||||||
|
}) {
|
||||||
|
return startBackgroundTask(
|
||||||
|
{
|
||||||
|
type: input.type,
|
||||||
|
title: input.title,
|
||||||
|
dedupeKey: input.dedupeKey || 'refresh-models-and-rebuild-routes',
|
||||||
|
notifyOnFailure: input.notifyOnFailure ?? true,
|
||||||
|
successMessage: input.successMessage,
|
||||||
|
failureMessage: input.failureMessage,
|
||||||
|
},
|
||||||
|
async () => refreshModelsAndRebuildRoutes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@ type ParsedSiteProxyInput = {
|
|||||||
proxyUrl: string | null;
|
proxyUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SiteProxyConfigLike = {
|
export type SiteProxyConfigLike = {
|
||||||
proxyUrl?: string | null;
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean | null;
|
useSystemProxy?: boolean | null;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ type Candidate = {
|
|||||||
|
|
||||||
describe('filterRecentlyFailedCandidates', () => {
|
describe('filterRecentlyFailedCandidates', () => {
|
||||||
it('rejects unsafe nested-quantifier regex route patterns', () => {
|
it('rejects unsafe nested-quantifier regex route patterns', () => {
|
||||||
expect(parseRegexModelPattern('re:^(a+)+$')).toBeNull();
|
expect(parseRegexModelPattern('re:(?=claude)')).toBeNull();
|
||||||
expect(matchesModelPattern('aaaa', 're:^(a+)+$')).toBe(false);
|
expect(matchesModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a short default recent-failure window', () => {
|
it('uses a short default recent-failure window', () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { eq, inArray } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { minimatch } from 'minimatch';
|
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { upsertSetting } from '../db/upsertSetting.js';
|
import { upsertSetting } from '../db/upsertSetting.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
@@ -10,7 +9,19 @@ import {
|
|||||||
} from './routeRoutingStrategy.js';
|
} from './routeRoutingStrategy.js';
|
||||||
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||||
import { isUsableAccountToken } from './accountTokenService.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 {
|
interface RouteMatch {
|
||||||
route: RouteRow;
|
route: RouteRow;
|
||||||
@@ -61,7 +72,6 @@ const FAILURE_BACKOFF_BASE_SEC = 15;
|
|||||||
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
|
const MIN_EFFECTIVE_UNIT_COST = 1e-6;
|
||||||
const ROUND_ROBIN_FAILURE_THRESHOLD = 3;
|
const ROUND_ROBIN_FAILURE_THRESHOLD = 3;
|
||||||
const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const;
|
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_HEALTH_DECAY_HALF_LIFE_MS = 10 * 60 * 1000;
|
||||||
const SITE_RUNTIME_MIN_MULTIPLIER = 0.08;
|
const SITE_RUNTIME_MIN_MULTIPLIER = 0.08;
|
||||||
const SITE_RUNTIME_LATENCY_BASELINE_MS = 2_500;
|
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 & {
|
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
|
||||||
routeMode: RouteMode;
|
routeMode: RouteMode;
|
||||||
sourceRouteIds: number[];
|
sourceRouteIds: number[];
|
||||||
@@ -848,33 +857,11 @@ export function filterRecentlyFailedCandidates<T extends { channel: FailureAware
|
|||||||
return healthy.length > 0 ? healthy : candidates;
|
return healthy.length > 0 ? healthy : candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteDecisionCandidate {
|
export type RouteDecisionExplanation = RouteDecision & {
|
||||||
channelId: number;
|
|
||||||
accountId: number;
|
|
||||||
username: string;
|
|
||||||
siteName: string;
|
|
||||||
tokenName: string;
|
|
||||||
priority: number;
|
|
||||||
weight: number;
|
|
||||||
eligible: boolean;
|
|
||||||
recentlyFailed: boolean;
|
|
||||||
avoidedByRecentFailure: boolean;
|
|
||||||
probability: number;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RouteDecisionExplanation {
|
|
||||||
requestedModel: string;
|
|
||||||
actualModel: string;
|
|
||||||
matched: boolean;
|
|
||||||
routeId?: number;
|
routeId?: number;
|
||||||
modelPattern?: string;
|
modelPattern?: string;
|
||||||
selectedChannelId?: number;
|
|
||||||
selectedAccountId?: number;
|
selectedAccountId?: number;
|
||||||
selectedLabel?: string;
|
};
|
||||||
summary: string[];
|
|
||||||
candidates: RouteDecisionCandidate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
||||||
|
|
||||||
@@ -904,154 +891,23 @@ type CostSignal = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function isRegexModelPattern(pattern: string): boolean {
|
export function isRegexModelPattern(pattern: string): boolean {
|
||||||
return pattern.trim().toLowerCase().startsWith('re:');
|
return isTokenRouteRegexPattern(pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRegexQuantifierLength(pattern: string, startIndex: number): number {
|
export function parseRegexModelPattern(pattern: string): { test(value: string): boolean } | null {
|
||||||
const ch = pattern[startIndex];
|
return parseTokenRouteRegexPattern(pattern).regex;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesModelPattern(model: string, pattern: string): boolean {
|
export function matchesModelPattern(model: string, pattern: string): boolean {
|
||||||
const normalizedPattern = (pattern || '').trim();
|
return matchesTokenRouteModelPattern(model, pattern);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExactRouteModelPattern(pattern: string): boolean {
|
function isExactRouteModelPattern(pattern: string): boolean {
|
||||||
const normalizedPattern = (pattern || '').trim();
|
return isExactTokenRouteModelPattern(pattern);
|
||||||
if (!normalizedPattern) return false;
|
|
||||||
if (isRegexModelPattern(normalizedPattern)) return false;
|
|
||||||
return !/[\*\?\[]/.test(normalizedPattern);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRouteMode(routeMode: string | null | undefined): RouteMode {
|
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 {
|
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
|
||||||
@@ -2037,7 +1893,7 @@ export class TokenRouter {
|
|||||||
return token ? token : null;
|
return token ? token : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getOauthInfoFromExtraConfig(candidate.account.extraConfig)) {
|
if (getOauthInfoFromAccount(candidate.account)) {
|
||||||
const accessToken = candidate.account.accessToken?.trim();
|
const accessToken = candidate.account.accessToken?.trim();
|
||||||
if (accessToken) return accessToken;
|
if (accessToken) return accessToken;
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -54,61 +54,6 @@ function validateSystemPrompts(body: Record<string, unknown>): { statusCode: num
|
|||||||
return undefined;
|
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(
|
function sanitizeAnthropicInboundBody(
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
|
): { sanitizedBody?: Record<string, unknown>; error?: { statusCode: number; payload: unknown } } {
|
||||||
@@ -118,12 +63,6 @@ function sanitizeAnthropicInboundBody(
|
|||||||
const systemError = validateSystemPrompts(body);
|
const systemError = validateSystemPrompts(body);
|
||||||
if (systemError) return { error: systemError };
|
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, {
|
const validation = validateAnthropicMessagesBody(body, {
|
||||||
autoOptimizeCacheControls: false,
|
autoOptimizeCacheControls: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
geminiGenerateContentTransformer,
|
geminiGenerateContentTransformer,
|
||||||
|
resolveGeminiGenerateContentUrl,
|
||||||
|
resolveGeminiModelsUrl,
|
||||||
|
resolveGeminiNativeBaseUrl,
|
||||||
reasoningEffortToGeminiThinkingConfig,
|
reasoningEffortToGeminiThinkingConfig,
|
||||||
geminiThinkingConfigToReasoning,
|
geminiThinkingConfigToReasoning,
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
import { extractGeminiUsage } from './usage.js';
|
import { extractGeminiUsage } from './usage.js';
|
||||||
import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js';
|
import { serializeGeminiAggregateResponse, extractResponseMetadata, geminiGenerateContentOutbound } from './outbound.js';
|
||||||
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
||||||
import {
|
import {
|
||||||
parseGeminiStreamPayload,
|
parseGeminiStreamPayload,
|
||||||
@@ -15,6 +18,33 @@ import {
|
|||||||
} from './stream.js';
|
} from './stream.js';
|
||||||
|
|
||||||
describe('geminiGenerateContentTransformer.inbound', () => {
|
describe('geminiGenerateContentTransformer.inbound', () => {
|
||||||
|
it('reuses the same gemini url resolver helpers across transformer layers', () => {
|
||||||
|
expect(geminiGenerateContentTransformer.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl);
|
||||||
|
expect(geminiGenerateContentTransformer.resolveModelsUrl).toBe(resolveGeminiModelsUrl);
|
||||||
|
expect(geminiGenerateContentTransformer.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl);
|
||||||
|
expect(geminiGenerateContentOutbound.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl);
|
||||||
|
expect(geminiGenerateContentOutbound.resolveModelsUrl).toBe(resolveGeminiModelsUrl);
|
||||||
|
expect(geminiGenerateContentOutbound.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves base-url query params when resolving Gemini endpoints', () => {
|
||||||
|
expect(
|
||||||
|
resolveGeminiNativeBaseUrl('https://example.com/native?alt=sse', 'v1beta'),
|
||||||
|
).toBe('https://example.com/native/v1beta?alt=sse');
|
||||||
|
expect(
|
||||||
|
resolveGeminiModelsUrl('https://example.com/native?alt=sse', 'v1beta', 'api-key'),
|
||||||
|
).toBe('https://example.com/native/v1beta/models?alt=sse&key=api-key');
|
||||||
|
expect(
|
||||||
|
resolveGeminiGenerateContentUrl(
|
||||||
|
'https://example.com/native?alt=sse',
|
||||||
|
'v1beta',
|
||||||
|
'/models/gemini-2.5-pro:generateContent',
|
||||||
|
'api-key',
|
||||||
|
'?trace=1',
|
||||||
|
),
|
||||||
|
).toBe('https://example.com/native/v1beta/models/gemini-2.5-pro:generateContent?alt=sse&trace=1&key=api-key');
|
||||||
|
});
|
||||||
|
|
||||||
it('parses native Gemini requests into canonical envelopes', () => {
|
it('parses native Gemini requests into canonical envelopes', () => {
|
||||||
const result = geminiGenerateContentTransformer.parseRequest({
|
const result = geminiGenerateContentTransformer.parseRequest({
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js';
|
import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js';
|
||||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
import {
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
resolveGeminiGenerateContentUrl,
|
||||||
}
|
resolveGeminiModelsUrl,
|
||||||
|
resolveGeminiNativeBaseUrl,
|
||||||
function baseIncludesVersion(baseUrl: string): boolean {
|
} from './urlResolver.js';
|
||||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
export {
|
||||||
}
|
resolveGeminiGenerateContentUrl,
|
||||||
|
resolveGeminiModelsUrl,
|
||||||
|
resolveGeminiNativeBaseUrl,
|
||||||
|
} from './urlResolver.js';
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -17,37 +20,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
|
||||||
const normalized = normalizeBaseUrl(baseUrl);
|
|
||||||
if (baseIncludesVersion(normalized)) return normalized;
|
|
||||||
return `${normalized}/${apiVersion}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGeminiModelsUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: string,
|
|
||||||
apiKey: string,
|
|
||||||
): string {
|
|
||||||
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
|
||||||
const separator = base.includes('?') ? '&' : '?';
|
|
||||||
return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGeminiGenerateContentUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: string,
|
|
||||||
modelActionPath: string,
|
|
||||||
apiKey: string,
|
|
||||||
search: string,
|
|
||||||
): string {
|
|
||||||
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
|
||||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
|
||||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
|
||||||
params.set('key', apiKey);
|
|
||||||
const query = params.toString();
|
|
||||||
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string {
|
export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string {
|
||||||
return asTrimmedString(params?.geminiApiVersion) || 'v1beta';
|
return asTrimmedString(params?.geminiApiVersion) || 'v1beta';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,45 +7,11 @@ import {
|
|||||||
toTransformerMetadataRecord,
|
toTransformerMetadataRecord,
|
||||||
type TransformerMetadata,
|
type TransformerMetadata,
|
||||||
} from '../../shared/normalized.js';
|
} from '../../shared/normalized.js';
|
||||||
|
import {
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
resolveGeminiGenerateContentUrl,
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
resolveGeminiModelsUrl,
|
||||||
}
|
resolveGeminiNativeBaseUrl,
|
||||||
|
} from './urlResolver.js';
|
||||||
function baseIncludesVersion(baseUrl: string): boolean {
|
|
||||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBaseUrl(baseUrl: string, apiVersion: string): string {
|
|
||||||
const normalized = normalizeBaseUrl(baseUrl);
|
|
||||||
if (baseIncludesVersion(normalized)) return normalized;
|
|
||||||
return `${normalized}/${apiVersion}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModelsUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: string,
|
|
||||||
apiKey: string,
|
|
||||||
): string {
|
|
||||||
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
|
|
||||||
const separator = resolvedBaseUrl.includes('?') ? '&' : '?';
|
|
||||||
return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveActionUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: string,
|
|
||||||
modelActionPath: string,
|
|
||||||
apiKey: string,
|
|
||||||
search: string,
|
|
||||||
): string {
|
|
||||||
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
|
|
||||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
|
||||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
|
||||||
params.set('key', apiKey);
|
|
||||||
const query = params.toString();
|
|
||||||
return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeminiRecord = Record<string, unknown>;
|
type GeminiRecord = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -304,9 +270,9 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const geminiGenerateContentOutbound = {
|
export const geminiGenerateContentOutbound = {
|
||||||
resolveBaseUrl,
|
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||||
resolveModelsUrl,
|
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||||
resolveActionUrl,
|
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||||
extractTransformerMetadata,
|
extractTransformerMetadata,
|
||||||
extractResponseMetadata,
|
extractResponseMetadata,
|
||||||
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
function normalizePathname(pathname: string): string {
|
||||||
|
let normalized = pathname || '';
|
||||||
|
while (normalized.endsWith('/')) {
|
||||||
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitBaseUrl(baseUrl: string): { path: string; query: string } {
|
||||||
|
const raw = baseUrl || '';
|
||||||
|
const queryIndex = raw.indexOf('?');
|
||||||
|
if (queryIndex < 0) {
|
||||||
|
return { path: normalizePathname(raw), query: '' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: normalizePathname(raw.slice(0, queryIndex)),
|
||||||
|
query: raw.slice(queryIndex + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseIncludesVersion(path: string): boolean {
|
||||||
|
return /\/v\d+(?:beta)?(?:\/|$)/i.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
||||||
|
const { path, query } = splitBaseUrl(baseUrl);
|
||||||
|
const normalizedPath = baseIncludesVersion(path)
|
||||||
|
? path
|
||||||
|
: `${path}/${apiVersion.replace(/^\/+/, '')}`;
|
||||||
|
return `${normalizedPath}${query ? `?${query}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGeminiModelsUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
apiVersion: string,
|
||||||
|
apiKey: string,
|
||||||
|
): string {
|
||||||
|
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
||||||
|
const [path, query = ''] = base.split('?', 2);
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
params.set('key', apiKey);
|
||||||
|
return `${path}/models?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGeminiGenerateContentUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
apiVersion: string,
|
||||||
|
modelActionPath: string,
|
||||||
|
apiKey: string,
|
||||||
|
search: string,
|
||||||
|
): string {
|
||||||
|
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
||||||
|
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
||||||
|
const [path, baseQuery = ''] = base.split('?', 2);
|
||||||
|
const params = new URLSearchParams(baseQuery);
|
||||||
|
const extraParams = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||||
|
for (const [key, value] of extraParams) {
|
||||||
|
params.set(key, value);
|
||||||
|
}
|
||||||
|
params.set('key', apiKey);
|
||||||
|
const query = params.toString();
|
||||||
|
return `${path}/${normalizedAction}${query ? `?${query}` : ''}`;
|
||||||
|
}
|
||||||
@@ -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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
@@ -8,170 +16,6 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
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(
|
export function buildResponsesCompatibilityBodies(
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Record<string, unknown>[] {
|
): Record<string, unknown>[] {
|
||||||
@@ -321,53 +165,6 @@ export function shouldDowngradeResponsesChatToMessages(
|
|||||||
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
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 {
|
function toFiniteNumber(value: unknown): number | null {
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
@@ -418,50 +215,6 @@ function stripResponsesMetadata(
|
|||||||
return next;
|
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(
|
function buildStrictResponsesBody(
|
||||||
body: Record<string, unknown>,
|
body: Record<string, unknown>,
|
||||||
): Record<string, unknown> | null {
|
): Record<string, unknown> | null {
|
||||||
@@ -481,194 +234,10 @@ function buildStrictResponsesBody(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_RESPONSES_FIELDS = new Set([
|
export {
|
||||||
'model',
|
convertOpenAiBodyToResponsesBody,
|
||||||
'input',
|
normalizeResponsesInputForCompatibility,
|
||||||
'instructions',
|
normalizeResponsesMessageContent,
|
||||||
'max_output_tokens',
|
normalizeResponsesMessageItem,
|
||||||
'max_completion_tokens',
|
sanitizeResponsesBodyForProxy,
|
||||||
'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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,119 @@ import {
|
|||||||
convertResponsesBodyToOpenAiBody,
|
convertResponsesBodyToOpenAiBody,
|
||||||
sanitizeResponsesBodyForProxy,
|
sanitizeResponsesBodyForProxy,
|
||||||
} from './conversion.js';
|
} 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves extra properties when normalizing role-only message items', () => {
|
||||||
|
expect(normalizeResponsesInputForCompatibility([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'done',
|
||||||
|
id: 'msg_1',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
])).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'output_text', text: 'done' }],
|
||||||
|
id: 'msg_1',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters whitespace-only string entries from normalized responses input arrays', () => {
|
||||||
|
expect(normalizeResponsesInputForCompatibility([
|
||||||
|
'hello',
|
||||||
|
' ',
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'world',
|
||||||
|
},
|
||||||
|
])).toEqual([
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'world' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to non-empty text and image sources when earlier compatibility fields are blank', () => {
|
||||||
|
const normalized = normalizeResponsesInputForCompatibility([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
text: 'done',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: ' ', content: 'hello' },
|
||||||
|
{ type: 'image_url', image_url: ' ', url: 'https://example.com/image.png' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(normalized).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
text: 'done',
|
||||||
|
content: [{ type: 'output_text', text: 'done' }],
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'input_text',
|
||||||
|
text: 'hello',
|
||||||
|
content: 'hello',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'input_image',
|
||||||
|
image_url: 'https://example.com/image.png',
|
||||||
|
url: 'https://example.com/image.png',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sanitizeResponsesBodyForProxy', () => {
|
describe('sanitizeResponsesBodyForProxy', () => {
|
||||||
it('preserves newer Responses request fields needed by the proxy', () => {
|
it('preserves newer Responses request fields needed by the proxy', () => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
normalizeResponsesInputForCompatibility as normalizeResponsesInputForCompatibilityViaCompatibility,
|
normalizeResponsesInputForCompatibility,
|
||||||
|
normalizeResponsesMessageContentBlocks,
|
||||||
normalizeResponsesMessageItem,
|
normalizeResponsesMessageItem,
|
||||||
} from './compatibility.js';
|
} from './normalization.js';
|
||||||
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
|
import { normalizeInputFileBlock, toOpenAiChatFileBlock } from '../../shared/inputFile.js';
|
||||||
import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js';
|
import { buildShortToolNameMap, getShortToolName } from '../../shared/toolNameShortener.js';
|
||||||
|
|
||||||
@@ -236,10 +237,6 @@ function normalizeToolOutput(raw: unknown): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeResponsesInputForCompatibility(input: unknown): unknown {
|
|
||||||
return normalizeResponsesInputForCompatibilityViaCompatibility(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
function toResponsesInputMessageFromText(text: string): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
@@ -366,17 +363,7 @@ function normalizeResponsesBodyForCompatibility(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
|
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
|
||||||
const normalized = normalizeResponsesMessageItem({
|
return normalizeResponsesMessageContentBlocks(role, content);
|
||||||
type: 'message',
|
|
||||||
role,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isRecord(normalized) && Array.isArray(normalized.content)) {
|
|
||||||
return normalized.content.filter((item): item is Record<string, unknown> => isRecord(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESPONSES_COMPATIBILITY_FILTER_FIELDS = new Set([
|
const RESPONSES_COMPATIBILITY_FILTER_FIELDS = new Set([
|
||||||
@@ -883,3 +870,5 @@ export function convertResponsesBodyToOpenAiBody(
|
|||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { normalizeResponsesInputForCompatibility };
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js';
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonEmptyTrimmedString(...values: unknown[]): string {
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = asTrimmedString(value);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMeaningfulValue(...values: unknown[]): unknown {
|
||||||
|
for (const value of values) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (value.trim()) return value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value !== undefined && value !== null) return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = firstNonEmptyTrimmedString(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 = firstNonEmptyTrimmedString(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) ?? normalizeImageUrlValue(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(
|
||||||
|
firstMeaningfulValue(item.content, item.text),
|
||||||
|
role,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (type === 'message') {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
role,
|
||||||
|
content: normalizedContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asTrimmedString(item.role)) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
type: 'message',
|
||||||
|
role,
|
||||||
|
content: normalizedContent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.content === 'string' || typeof item.text === 'string') {
|
||||||
|
const text = firstNonEmptyTrimmedString(item.content, item.text);
|
||||||
|
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.flatMap((item) => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
const normalized = item.trim();
|
||||||
|
return normalized ? [toResponsesInputMessageFromText(normalized)] : [];
|
||||||
|
}
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
|
||||||
|
|
||||||
|
export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[];
|
||||||
|
export declare function inferConversationFileMimeType(filename: string | null | undefined): string;
|
||||||
|
export declare function resolveConversationFileMimeType(
|
||||||
|
mimeType: string | null | undefined,
|
||||||
|
filename: string | null | undefined,
|
||||||
|
): string;
|
||||||
|
export declare function classifyConversationFileMimeType(
|
||||||
|
mimeType: string | null | undefined,
|
||||||
|
): Exclude<ConversationFileKind, 'unknown'>;
|
||||||
|
export declare function detectConversationFileKind(file: {
|
||||||
|
filename?: string | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
}): ConversationFileKind;
|
||||||
|
export declare function isSupportedConversationFileMimeType(mimeType: string): boolean;
|
||||||
|
export declare function buildConversationAcceptList(input: {
|
||||||
|
document: boolean;
|
||||||
|
image: boolean;
|
||||||
|
audio: boolean;
|
||||||
|
}): string;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user