fix: address review feedback and code scanning alerts

This commit is contained in:
Cita
2026-03-22 23:41:45 +08:00
parent 32d7037629
commit 5d78658af9
38 changed files with 929 additions and 344 deletions
@@ -10,7 +10,7 @@
--- ---
### Task 1: Savepoint Commit ## Task 1: Savepoint Commit
**Files:** **Files:**
- Commit: `src/server/config.ts` - Commit: `src/server/config.ts`
@@ -42,7 +42,7 @@ git cherry-pick <savepoint-commit>
Expected: first commit preserved inside PR1 branch before the larger consolidation commit. Expected: first commit preserved inside PR1 branch before the larger consolidation commit.
### Task 2: Responses Single Source ## Task 2: Responses Single Source
**Files:** **Files:**
- Create: `src/server/transformers/openai/responses/normalization.ts` - Create: `src/server/transformers/openai/responses/normalization.ts`
@@ -63,7 +63,7 @@ Make `conversion.ts` and `compatibility.ts` import the shared helpers instead of
Run: `npm test -- src/server/transformers/openai/responses/conversion.test.ts` Run: `npm test -- src/server/transformers/openai/responses/conversion.test.ts`
Expected: PASS, including assertions that compatibility exports point at the shared implementation. Expected: PASS, including assertions that compatibility exports point at the shared implementation.
### Task 3: Shared Surface Orchestration ## Task 3: Shared Surface Orchestration
**Files:** **Files:**
- Create: `src/server/proxy-core/surfaces/sharedSurface.ts` - Create: `src/server/proxy-core/surfaces/sharedSurface.ts`
@@ -85,7 +85,7 @@ Make chat and responses surfaces consume the shared orchestration helpers instea
Run: `npm test -- src/server/proxy-core/surfaces/sharedSurface.test.ts` Run: `npm test -- src/server/proxy-core/surfaces/sharedSurface.test.ts`
Expected: PASS Expected: PASS
### Task 4: Provider Header Builders ## Task 4: Provider Header Builders
**Files:** **Files:**
- Create: `src/server/proxy-core/providers/headerUtils.ts` - Create: `src/server/proxy-core/providers/headerUtils.ts`
@@ -108,7 +108,7 @@ Make both provider profiles and `upstreamEndpoint.ts` call the shared header bui
Run: `npm test -- src/server/proxy-core/providers/headerUtils.test.ts` Run: `npm test -- src/server/proxy-core/providers/headerUtils.test.ts`
Expected: PASS Expected: PASS
### Task 5: Cross-Layer Token Route Contract ## Task 5: Cross-Layer Token Route Contract
**Files:** **Files:**
- Create: `src/shared/tokenRouteContract.js` - Create: `src/shared/tokenRouteContract.js`
@@ -137,7 +137,7 @@ Make token router, route APIs, token-routes helpers, and settings consume the sh
Run: `npm test -- src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.test.ts` Run: `npm test -- src/shared/tokenRouteContract.test.ts src/shared/tokenRoutePatterns.test.ts`
Expected: PASS Expected: PASS
### Task 6: Account Mutation Workflow ## Task 6: Account Mutation Workflow
**Files:** **Files:**
- Create: `src/server/services/accountMutationWorkflow.ts` - Create: `src/server/services/accountMutationWorkflow.ts`
@@ -158,7 +158,7 @@ Make account and account-token APIs call the workflow service instead of each ow
Run: `npm test -- src/server/services/accountMutationWorkflow.test.ts` Run: `npm test -- src/server/services/accountMutationWorkflow.test.ts`
Expected: PASS Expected: PASS
### Task 7: Standard API Provider Base ## Task 7: Standard API Provider Base
**Files:** **Files:**
- Create: `src/server/services/platforms/standardApiProvider.ts` - Create: `src/server/services/platforms/standardApiProvider.ts`
@@ -182,7 +182,7 @@ Make OpenAI, Claude, Gemini, and CLIProxyAPI adapters extend the shared base ins
Run: `npm test -- src/server/services/platforms/standardApiProvider.test.ts src/server/services/platforms/llmUpstream.test.ts` Run: `npm test -- src/server/services/platforms/standardApiProvider.test.ts src/server/services/platforms/llmUpstream.test.ts`
Expected: PASS Expected: PASS
### Task 8: Shared Input File Resolution And Web Helpers ## Task 8: Shared Input File Resolution And Web Helpers
**Files:** **Files:**
- Modify: `src/server/services/proxyInputFileResolver.ts` - Modify: `src/server/services/proxyInputFileResolver.ts`
@@ -212,7 +212,7 @@ Make `testProxy` / `proxyTest` and stream variants reuse the same implementation
Run: `npm test -- src/server/services/proxyInputFileResolver.test.ts src/web/pages/helpers/accountConnection.test.ts src/web/api.test.ts` Run: `npm test -- src/server/services/proxyInputFileResolver.test.ts src/web/pages/helpers/accountConnection.test.ts src/web/api.test.ts`
Expected: PASS Expected: PASS
### Task 9: PR1 Verification And Commit ## Task 9: PR1 Verification And Commit
**Files:** **Files:**
- Verify: `scripts/dev/copy-runtime-db-generated.ts` - Verify: `scripts/dev/copy-runtime-db-generated.ts`
@@ -71,7 +71,7 @@ describe('ensureProxyFileSchemaCompatibility', () => {
'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
); );
expect(executedSql).toContain( expect(executedSql).toContain(
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`)', 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))',
); );
}); });
@@ -128,7 +128,7 @@ const CREATE_INDEX_SQL: Record<ProxyFileSchemaDialect, string[]> = {
], ],
mysql: [ mysql: [
'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))', 'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`)', 'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))',
], ],
}; };
@@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest';
import { detectCodexOfficialClientApp } from './codexProfile.js'; import { detectCodexOfficialClientApp } from './codexProfile.js';
describe('detectCodexOfficialClientApp', () => { describe('detectCodexOfficialClientApp', () => {
it('returns null for missing or empty headers', () => {
expect(detectCodexOfficialClientApp()).toBeNull();
expect(detectCodexOfficialClientApp({})).toBeNull();
});
it('detects official Codex clients from originator prefixes', () => { it('detects official Codex clients from originator prefixes', () => {
expect(detectCodexOfficialClientApp({ expect(detectCodexOfficialClientApp({
originator: 'codex_exec', originator: 'codex_exec',
@@ -20,6 +25,30 @@ describe('detectCodexOfficialClientApp', () => {
}); });
}); });
it('matches headers case-insensitively and from header arrays', () => {
expect(detectCodexOfficialClientApp({
originator: 'CODEX_EXEC',
})).toEqual({
clientAppId: 'codex_exec',
clientAppName: 'Codex Exec',
});
expect(detectCodexOfficialClientApp({
'user-agent': 'Mozilla/5.0 CODEX_chatgpt_desktop/1.2',
})).toEqual({
clientAppId: 'codex_chatgpt_desktop',
clientAppName: 'Codex Desktop',
});
expect(detectCodexOfficialClientApp({
'user-agent': ['Mozilla/5.0', 'codex_chatgpt_desktop/1.2'],
})).toEqual({
clientAppId: 'codex_chatgpt_desktop',
clientAppName: 'Codex Desktop',
});
expect(detectCodexOfficialClientApp({
originator: ['ignored', 'other-client'],
})).toBeNull();
});
it('returns null for non-official Codex clients', () => { it('returns null for non-official Codex clients', () => {
expect(detectCodexOfficialClientApp({ expect(detectCodexOfficialClientApp({
'user-agent': 'OpenClaw/1.0', 'user-agent': 'OpenClaw/1.0',
@@ -66,33 +66,43 @@ const CODEX_OFFICIAL_CLIENT_APP_RULES = [
}, },
] as const; ] as const;
function headerValueToString(value: unknown): string | null { 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 {
@@ -104,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 {
@@ -127,10 +139,8 @@ export function detectCodexOfficialClientApp(
headers?: Record<string, unknown>, headers?: Record<string, unknown>,
): CodexOfficialClientApp | null { ): CodexOfficialClientApp | null {
for (const rule of CODEX_OFFICIAL_CLIENT_APP_RULES) { for (const rule of CODEX_OFFICIAL_CLIENT_APP_RULES) {
const matchesOriginator = Array.isArray(rule.originatorPrefixes) const matchesOriginator = matchesHeaderPrefixes(getHeaderValues(headers, 'originator'), rule.originatorPrefixes);
&& matchesHeaderPrefixes(getHeaderValue(headers, 'originator'), [...rule.originatorPrefixes]); const matchesUserAgent = matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), rule.userAgentPrefixes);
const matchesUserAgent = Array.isArray(rule.userAgentPrefixes)
&& matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), [...rule.userAgentPrefixes]);
if (!matchesOriginator && !matchesUserAgent) continue; if (!matchesOriginator && !matchesUserAgent) continue;
return { return {
clientAppId: rule.id, clientAppId: rule.id,
@@ -156,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;
@@ -101,7 +101,7 @@ describe('provider header utils', () => {
const headers = buildGeminiCliRuntimeHeaders({ const headers = buildGeminiCliRuntimeHeaders({
baseHeaders: { baseHeaders: {
Authorization: 'Bearer test', authorization: 'Bearer test',
}, },
providerHeaders: { providerHeaders: {
'x-goog-api-client': 'gl-node/22 gccl/0.31.0', 'x-goog-api-client': 'gl-node/22 gccl/0.31.0',
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from 'node:crypto'; import { pbkdf2Sync, randomUUID } from 'node:crypto';
const CODEX_CLIENT_VERSION = '0.101.0'; const CODEX_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 CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
@@ -33,8 +33,8 @@ export function getInputHeader(
} }
export function uuidFromSeed(seed: string): string { export function uuidFromSeed(seed: string): string {
const hash = createHash('sha1').update(seed).digest(); const derived = pbkdf2Sync(seed, 'metapi-runtime-header-seed', 10_000, 16, 'sha256');
const bytes = new Uint8Array(hash.subarray(0, 16)); const bytes = new Uint8Array(derived);
bytes[6] = (bytes[6]! & 0x0f) | 0x50; bytes[6] = (bytes[6]! & 0x0f) | 0x50;
bytes[8] = (bytes[8]! & 0x3f) | 0x80; bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
@@ -153,7 +153,7 @@ export function buildCodexRuntimeHeaders(input: {
); );
return { return {
Authorization: authorization, ...(authorization ? { Authorization: authorization } : {}),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}), ...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
Originator: originator, Originator: originator,
@@ -221,6 +221,7 @@ export function buildGeminiCliRuntimeHeaders(input: {
modelName: string; modelName: string;
stream: boolean; stream: boolean;
}): Record<string, string> { }): Record<string, string> {
const authorization = getInputHeader(input.baseHeaders, 'authorization');
const apiClient = ( const apiClient = (
getInputHeader(input.providerHeaders, 'x-goog-api-client') getInputHeader(input.providerHeaders, 'x-goog-api-client')
|| getInputHeader(input.baseHeaders, 'x-goog-api-client') || getInputHeader(input.baseHeaders, 'x-goog-api-client')
@@ -231,7 +232,7 @@ export function buildGeminiCliRuntimeHeaders(input: {
); );
const headers: Record<string, string> = { const headers: Record<string, string> = {
Authorization: input.baseHeaders.Authorization, ...(authorization ? { Authorization: authorization } : {}),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': userAgent, 'User-Agent': userAgent,
}; };
@@ -808,7 +808,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
retryCount += 1; retryCount += 1;
continue; continue;
} }
return reply.code(failureOutcome.status).type(contentType).send(payload); return reply.code(failureOutcome.status).send(failureOutcome.payload);
} }
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName); tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
+5 -17
View File
@@ -8,7 +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 } from '../../../shared/conversationFileTypes.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' } });
@@ -44,21 +47,6 @@ function toFileObject(record: {
}; };
} }
function inferMimeTypeFromFilename(filename: string): string {
const normalized = filename.trim().toLowerCase();
if (normalized.endsWith('.pdf')) return 'application/pdf';
if (normalized.endsWith('.txt')) return 'text/plain';
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
if (normalized.endsWith('.json')) return 'application/json';
if (normalized.endsWith('.png')) return 'image/png';
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
if (normalized.endsWith('.gif')) return 'image/gif';
if (normalized.endsWith('.webp')) return 'image/webp';
if (normalized.endsWith('.wav')) return 'audio/wav';
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
return 'application/octet-stream';
}
export async function filesProxyRoute(app: FastifyInstance) { export async function filesProxyRoute(app: FastifyInstance) {
ensureMultipartBufferParser(app); ensureMultipartBufferParser(app);
@@ -79,7 +67,7 @@ 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 (!isSupportedConversationFileMimeType(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'}`);
} }
@@ -715,46 +715,50 @@ export async function handleOpenAiResponsesSurfaceRequest(
usage: parsedUsage, usage: parsedUsage,
serializationMode: isCompactRequest ? 'compact' : 'response', serializationMode: isCompactRequest ? 'compact' : 'response',
}); });
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ try {
site: selected.site, const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
account: selected.account, site: selected.site,
tokenValue: selected.tokenValue, account: selected.account,
tokenName: selected.tokenName, tokenValue: selected.tokenValue,
modelName: selected.actualModel || requestedModel, tokenName: selected.tokenName,
requestStartedAtMs: startTime, modelName: selected.actualModel || requestedModel,
requestEndedAtMs: startTime + latency, requestStartedAtMs: startTime,
localLatencyMs: latency, requestEndedAtMs: startTime + latency,
usage: { localLatencyMs: latency,
promptTokens: parsedUsage.promptTokens, usage: {
completionTokens: parsedUsage.completionTokens, promptTokens: parsedUsage.promptTokens,
totalTokens: parsedUsage.totalTokens, completionTokens: parsedUsage.completionTokens,
}, totalTokens: parsedUsage.totalTokens,
}); },
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ });
site: selected.site, const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
account: selected.account, site: selected.site,
modelName: selected.actualModel || requestedModel, account: selected.account,
parsedUsage, modelName: selected.actualModel || requestedModel,
resolvedUsage, parsedUsage,
}); resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName); tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost); recordDownstreamCostUsage(request, estimatedCost);
await failureToolkit.log({ await failureToolkit.log({
selected, selected,
modelRequested: requestedModel, modelRequested: requestedModel,
status: 'success', status: 'success',
httpStatus: 200, httpStatus: 200,
latencyMs: latency, latencyMs: latency,
errorMessage: null, errorMessage: null,
retryCount, retryCount,
promptTokens: resolvedUsage.promptTokens, promptTokens: resolvedUsage.promptTokens,
completionTokens: resolvedUsage.completionTokens, completionTokens: resolvedUsage.completionTokens,
totalTokens: resolvedUsage.totalTokens, totalTokens: resolvedUsage.totalTokens,
estimatedCost, estimatedCost,
billingDetails, billingDetails,
upstreamPath: successfulUpstreamPath, upstreamPath: successfulUpstreamPath,
}); });
} catch (error) {
console.error('[responses] post-response bookkeeping failed:', error);
}
return reply.send(downstreamData); return reply.send(downstreamData);
} catch (err: any) { } catch (err: any) {
const failureOutcome = await failureToolkit.handleExecutionError({ const failureOutcome = await failureToolkit.handleExecutionError({
@@ -206,7 +206,7 @@ export function createSurfaceFailureToolkit(input: {
retryCount: number; retryCount: number;
}): Promise<SurfaceFailureOutcome> { }): Promise<SurfaceFailureOutcome> {
const rawErrText = args.rawErrText || args.errText; const rawErrText = args.rawErrText || args.errText;
tokenRouter.recordFailure(args.selected.channel.id, { await tokenRouter.recordFailure(args.selected.channel.id, {
status: args.status, status: args.status,
errorText: rawErrText, errorText: rawErrText,
modelName: args.modelName, modelName: args.modelName,
@@ -269,7 +269,7 @@ export function createSurfaceFailureToolkit(input: {
totalTokens?: number; totalTokens?: number;
upstreamPath?: string | null; upstreamPath?: string | null;
}): Promise<SurfaceFailureOutcome> { }): Promise<SurfaceFailureOutcome> {
tokenRouter.recordFailure(args.selected.channel.id, { await tokenRouter.recordFailure(args.selected.channel.id, {
status: args.failure.status, status: args.failure.status,
errorText: args.failure.reason, errorText: args.failure.reason,
modelName: args.modelName, modelName: args.modelName,
@@ -318,7 +318,7 @@ export function createSurfaceFailureToolkit(input: {
latencyMs: number; latencyMs: number;
retryCount: number; retryCount: number;
}): Promise<SurfaceFailureOutcome> { }): Promise<SurfaceFailureOutcome> {
tokenRouter.recordFailure(args.selected.channel.id, { await tokenRouter.recordFailure(args.selected.channel.id, {
errorText: args.errorMessage, errorText: args.errorMessage,
modelName: args.modelName, modelName: args.modelName,
}); });
@@ -368,13 +368,13 @@ export function createSurfaceFailureToolkit(input: {
}) { }) {
const errorMessage = args.errorMessage || 'stream processing failed'; const errorMessage = args.errorMessage || 'stream processing failed';
if (typeof args.runtimeFailureStatus === 'number') { if (typeof args.runtimeFailureStatus === 'number') {
tokenRouter.recordFailure(args.selected.channel.id, { await tokenRouter.recordFailure(args.selected.channel.id, {
status: args.runtimeFailureStatus, status: args.runtimeFailureStatus,
errorText: errorMessage, errorText: errorMessage,
modelName: args.modelName, modelName: args.modelName,
}); });
} else { } else {
tokenRouter.recordFailure(args.selected.channel.id, args.modelName); await tokenRouter.recordFailure(args.selected.channel.id, args.modelName);
} }
await log({ await log({
selected: args.selected, selected: args.selected,
+33 -5
View File
@@ -228,11 +228,39 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
if (!row.accounts.accessToken) { if (!row.accounts.accessToken) {
if (row.accounts.apiToken) { if (row.accounts.apiToken) {
await convergeAccountMutation({ try {
accountId, const convergence = await convergeAccountMutation({
preferredApiToken: row.accounts.apiToken, accountId,
defaultTokenSource: 'legacy', preferredApiToken: row.accounts.apiToken,
}); defaultTokenSource: 'legacy',
rebuildRoutes: true,
});
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,
@@ -242,12 +242,12 @@ function detectProtocolClientApp(input: {
}; };
case 'codex': { case 'codex': {
const clientApp = detectCodexOfficialClientAppViaProfile(input.headers); const clientApp = detectCodexOfficialClientAppViaProfile(input.headers);
return (clientApp return clientApp
? { ? {
...clientApp, ...clientApp,
clientConfidence: 'exact' as const, clientConfidence: 'exact' as const,
} }
: null) || { : {
clientAppId: 'codex', clientAppId: 'codex',
clientAppName: 'Codex', clientAppName: 'Codex',
clientConfidence: 'heuristic', clientConfidence: 'heuristic',
@@ -77,6 +77,16 @@ export async function convergeAccountMutation(input: {
result.tokenSync = tokenSync; result.tokenSync = tokenSync;
result.defaultTokenId = tokenSync.defaultTokenId ?? result.defaultTokenId; 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()) { } else if (!input.ensurePreferredTokenBeforeSync && input.preferredApiToken?.trim()) {
const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount( const defaultTokenId = await runStep(() => ensureDefaultTokenForAccount(
input.accountId, input.accountId,
+5 -1
View File
@@ -617,7 +617,11 @@ function normalizeOriginUrl(raw: string): string {
function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string { function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string {
const rawPlatformText = asString(rawPlatform).toLowerCase(); const rawPlatformText = asString(rawPlatform).toLowerCase();
const normalizedPlatform = rawPlatformText const normalizedPlatform = rawPlatformText
? (PLATFORM_ALIASES[rawPlatformText] ?? (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '')) ? (
Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, rawPlatformText)
? PLATFORM_ALIASES[rawPlatformText]
: (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '')
)
: ''; : '';
if (normalizedPlatform) return normalizedPlatform; if (normalizedPlatform) return normalizedPlatform;
+5 -4
View File
@@ -516,15 +516,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 = getOauthInfoFromAccount(row.accounts)!; 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,
@@ -546,7 +547,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 };
@@ -13,7 +13,11 @@ type FetchModelsOptions = {
}; };
export function normalizePlatformBaseUrl(baseUrl: string): string { export function normalizePlatformBaseUrl(baseUrl: string): string {
return (baseUrl || '').replace(/\/+$/, ''); let normalized = baseUrl || '';
while (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
} }
export function resolveVersionedModelsUrl(baseUrl: string): string { export function resolveVersionedModelsUrl(baseUrl: string): string {
@@ -123,4 +123,46 @@ describe('proxyInputFileResolver', () => {
], ],
}); });
}); });
it('falls back from application/octet-stream to filename-based mime inference', async () => {
const { inlineLocalInputFileReferences } = await import('./proxyInputFileResolver.js');
await expect(inlineLocalInputFileReferences(
{
model: 'gpt-5',
messages: [
{
role: 'user',
content: [
{
type: 'file',
file: {
filename: 'paper.pdf',
mime_type: 'application/octet-stream',
file_data: Buffer.from('%PDF-octet').toString('base64'),
},
},
],
},
],
},
{ ownerType: 'managed_key', ownerId: '7' },
)).resolves.toEqual({
model: 'gpt-5',
messages: [
{
role: 'user',
content: [
{
type: 'file',
file: {
file_data: Buffer.from('%PDF-octet').toString('base64'),
filename: 'paper.pdf',
mime_type: 'application/pdf',
},
},
],
},
],
});
});
}); });
+11 -25
View File
@@ -1,10 +1,11 @@
import type { ProxyResourceOwner } from '../middleware/auth.js'; import type { ProxyResourceOwner } from '../middleware/auth.js';
import { getProxyFileByPublicIdForOwner } from './proxyFileStore.js'; import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js'; import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js';
import { isSupportedConversationFileMimeType } from '../../shared/conversationFileTypes.js'; import {
isSupportedConversationFileMimeType,
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-'; 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);
@@ -26,24 +27,6 @@ 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 isSupportedConversationFileMimeType(mimeType); return isSupportedConversationFileMimeType(mimeType);
} }
@@ -116,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}`);
} }
@@ -133,7 +116,10 @@ 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}`);
} }
@@ -145,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}`);
} }
+2 -2
View File
@@ -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 -1
View File
@@ -894,7 +894,7 @@ export function isRegexModelPattern(pattern: string): boolean {
return isTokenRouteRegexPattern(pattern); return isTokenRouteRegexPattern(pattern);
} }
export function parseRegexModelPattern(pattern: string): RegExp | null { export function parseRegexModelPattern(pattern: string): { test(value: string): boolean } | null {
return parseTokenRouteRegexPattern(pattern).regex; return parseTokenRouteRegexPattern(pattern).regex;
} }
@@ -27,6 +27,24 @@ describe('geminiGenerateContentTransformer.inbound', () => {
expect(geminiGenerateContentOutbound.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl); 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,15 +1,33 @@
function normalizeBaseUrl(baseUrl: string): string { function normalizePathname(pathname: string): string {
return (baseUrl || '').replace(/\/+$/, ''); let normalized = pathname || '';
while (normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
} }
function baseIncludesVersion(baseUrl: string): boolean { function splitBaseUrl(baseUrl: string): { path: string; query: string } {
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); 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 { export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
const normalized = normalizeBaseUrl(baseUrl); const { path, query } = splitBaseUrl(baseUrl);
if (baseIncludesVersion(normalized)) return normalized; const normalizedPath = baseIncludesVersion(path)
return `${normalized}/${apiVersion}`; ? path
: `${path}/${apiVersion.replace(/^\/+/, '')}`;
return `${normalizedPath}${query ? `?${query}` : ''}`;
} }
export function resolveGeminiModelsUrl( export function resolveGeminiModelsUrl(
@@ -18,8 +36,10 @@ export function resolveGeminiModelsUrl(
apiKey: string, apiKey: string,
): string { ): string {
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
const separator = base.includes('?') ? '&' : '?'; const [path, query = ''] = base.split('?', 2);
return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`; const params = new URLSearchParams(query);
params.set('key', apiKey);
return `${path}/models?${params.toString()}`;
} }
export function resolveGeminiGenerateContentUrl( export function resolveGeminiGenerateContentUrl(
@@ -31,8 +51,13 @@ export function resolveGeminiGenerateContentUrl(
): string { ): string {
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
const normalizedAction = modelActionPath.replace(/^\/+/, ''); const normalizedAction = modelActionPath.replace(/^\/+/, '');
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); const [path, baseQuery = ''] = base.split('?', 2);
const params = new URLSearchParams(baseQuery);
const extraParams = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
for (const [key, value] of extraParams) {
params.set(key, value);
}
params.set('key', apiKey); params.set('key', apiKey);
const query = params.toString(); const query = params.toString();
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`; return `${path}/${normalizedAction}${query ? `?${query}` : ''}`;
} }
@@ -1,7 +1,7 @@
import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js'; import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.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' && !Array.isArray(value);
} }
function asTrimmedString(value: unknown): string { function asTrimmedString(value: unknown): string {
+5
View File
@@ -1,6 +1,11 @@
export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown'; export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[]; 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( export declare function classifyConversationFileMimeType(
mimeType: string | null | undefined, mimeType: string | null | undefined,
): Exclude<ConversationFileKind, 'unknown'>; ): Exclude<ConversationFileKind, 'unknown'>;
+36
View File
@@ -1,4 +1,8 @@
export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json']; export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json'];
const GENERIC_MIME_TYPES = new Set([
'application/octet-stream',
'binary/octet-stream',
]);
const DOCUMENT_MIME_TYPES = new Set([ const DOCUMENT_MIME_TYPES = new Set([
'application/json', 'application/json',
'application/pdf', 'application/pdf',
@@ -13,6 +17,37 @@ function normalizeValue(value) {
return (value || '').trim().toLowerCase(); return (value || '').trim().toLowerCase();
} }
export function inferConversationFileMimeType(filename) {
const normalized = normalizeValue(filename);
if (normalized.endsWith('.pdf')) return 'application/pdf';
if (normalized.endsWith('.txt')) return 'text/plain';
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
if (normalized.endsWith('.json')) return 'application/json';
if (normalized.endsWith('.png')) return 'image/png';
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
if (normalized.endsWith('.gif')) return 'image/gif';
if (normalized.endsWith('.webp')) return 'image/webp';
if (normalized.endsWith('.svg')) return 'image/svg+xml';
if (normalized.endsWith('.avif')) return 'image/avif';
if (normalized.endsWith('.bmp')) return 'image/bmp';
if (normalized.endsWith('.wav')) return 'audio/wav';
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
if (normalized.endsWith('.m4a')) return 'audio/mp4';
if (normalized.endsWith('.ogg')) return 'audio/ogg';
if (normalized.endsWith('.aac')) return 'audio/aac';
if (normalized.endsWith('.flac')) return 'audio/flac';
if (normalized.endsWith('.weba')) return 'audio/webm';
return 'application/octet-stream';
}
export function resolveConversationFileMimeType(mimeType, filename) {
const normalizedMimeType = normalizeValue(mimeType);
if (normalizedMimeType && !GENERIC_MIME_TYPES.has(normalizedMimeType)) {
return normalizedMimeType;
}
return inferConversationFileMimeType(filename);
}
export function classifyConversationFileMimeType(mimeType) { export function classifyConversationFileMimeType(mimeType) {
const normalized = normalizeValue(mimeType); const normalized = normalizeValue(mimeType);
if (normalized.startsWith('image/')) return 'image'; if (normalized.startsWith('image/')) return 'image';
@@ -26,6 +61,7 @@ export function detectConversationFileKind(file) {
if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('audio/')) return 'audio'; if (mimeType.startsWith('audio/')) return 'audio';
if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document'; if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document';
if (!GENERIC_MIME_TYPES.has(mimeType)) return 'unknown';
} }
const filename = normalizeValue(file?.filename); const filename = normalizeValue(file?.filename);
+11
View File
@@ -4,7 +4,9 @@ import {
buildConversationAcceptList, buildConversationAcceptList,
classifyConversationFileMimeType, classifyConversationFileMimeType,
detectConversationFileKind, detectConversationFileKind,
inferConversationFileMimeType,
isSupportedConversationFileMimeType, isSupportedConversationFileMimeType,
resolveConversationFileMimeType,
} from './conversationFileTypes.js'; } from './conversationFileTypes.js';
describe('conversationFileTypes', () => { describe('conversationFileTypes', () => {
@@ -31,4 +33,13 @@ describe('conversationFileTypes', () => {
audio: false, audio: false,
})).toBe('.pdf,.txt,.md,.markdown,.json,image/*'); })).toBe('.pdf,.txt,.md,.markdown,.json,image/*');
}); });
it('falls back from generic mime types to filename-based inference consistently', () => {
expect(inferConversationFileMimeType('photo.avif')).toBe('image/avif');
expect(resolveConversationFileMimeType('application/octet-stream', 'paper.pdf')).toBe('application/pdf');
expect(detectConversationFileKind({
filename: 'paper.pdf',
mimeType: 'application/octet-stream',
})).toBe('document');
});
}); });
+46 -17
View File
@@ -1,4 +1,4 @@
export const PLATFORM_ALIASES = { export const PLATFORM_ALIASES = Object.assign(Object.create(null), {
anyrouter: 'anyrouter', anyrouter: 'anyrouter',
'wong-gongyi': 'new-api', 'wong-gongyi': 'new-api',
'vo-api': 'new-api', 'vo-api': 'new-api',
@@ -31,35 +31,64 @@ export const PLATFORM_ALIASES = {
cliproxyapi: 'cliproxyapi', cliproxyapi: 'cliproxyapi',
cpa: 'cliproxyapi', cpa: 'cliproxyapi',
'cli-proxy-api': 'cliproxyapi', 'cli-proxy-api': 'cliproxyapi',
}; });
function getPlatformAlias(raw) {
return Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, raw)
? PLATFORM_ALIASES[raw]
: undefined;
}
function normalizeUrlCandidate(url) {
return typeof url === 'string' ? url.trim() : '';
}
function parseUrlCandidate(url) {
const normalized = normalizeUrlCandidate(url);
if (!normalized) return null;
const candidates = normalized.includes('://')
? [normalized]
: [`https://${normalized}`];
for (const candidate of candidates) {
try {
return new URL(candidate);
} catch {}
}
return null;
}
export function normalizePlatformAlias(platform) { export function normalizePlatformAlias(platform) {
const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : ''; const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : '';
if (!raw) return ''; if (!raw) return '';
return PLATFORM_ALIASES[raw] ?? raw; return getPlatformAlias(raw) ?? raw;
} }
export function detectPlatformByUrlHint(url) { export function detectPlatformByUrlHint(url) {
const normalized = (url || '').trim().toLowerCase(); const normalized = normalizeUrlCandidate(url).toLowerCase();
if (!normalized) return undefined; if (!normalized) return undefined;
const parsed = parseUrlCandidate(normalized);
const host = parsed?.hostname?.trim().toLowerCase() || '';
const port = parsed?.port?.trim() || '';
const path = parsed?.pathname?.trim().toLowerCase() || '';
if (normalized.includes('api.openai.com')) return 'openai'; if (host === 'api.openai.com') return 'openai';
if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; if (host === 'chatgpt.com' && path.startsWith('/backend-api/codex')) return 'codex';
if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; if (host === 'api.anthropic.com' || (host === 'anthropic.com' && path.startsWith('/v1'))) return 'claude';
if ( if (
normalized.includes('generativelanguage.googleapis.com') host === 'generativelanguage.googleapis.com'
|| normalized.includes('googleapis.com/v1beta/openai') || host === 'gemini.google.com'
|| normalized.includes('gemini.google.com') || ((host === 'googleapis.com' || host.endsWith('.googleapis.com')) && path.startsWith('/v1beta/openai'))
) { ) {
return 'gemini'; return 'gemini';
} }
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; if (host === 'cloudcode-pa.googleapis.com') return 'gemini-cli';
if (normalized.includes('anyrouter')) return 'anyrouter'; if ((host === '127.0.0.1' || host === 'localhost') && port === '8317') return 'cliproxyapi';
if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; if (host.includes('anyrouter')) return 'anyrouter';
if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; if (host.includes('donehub') || host.includes('done-hub')) return 'done-hub';
if (normalized.includes('veloera')) return 'veloera'; if (host.includes('onehub') || host.includes('one-hub')) return 'one-hub';
if (normalized.includes('sub2api')) return 'sub2api'; if (host.includes('veloera')) return 'veloera';
if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; if (host.includes('sub2api')) return 'sub2api';
return undefined; return undefined;
} }
+1
View File
@@ -20,5 +20,6 @@ describe('platformIdentity', () => {
expect(detectPlatformByUrlHint('https://generativelanguage.googleapis.com/v1beta')).toBe('gemini'); expect(detectPlatformByUrlHint('https://generativelanguage.googleapis.com/v1beta')).toBe('gemini');
expect(detectPlatformByUrlHint('https://cloudcode-pa.googleapis.com')).toBe('gemini-cli'); expect(detectPlatformByUrlHint('https://cloudcode-pa.googleapis.com')).toBe('gemini-cli');
expect(detectPlatformByUrlHint('http://127.0.0.1:8317/v1/models')).toBe('cliproxyapi'); expect(detectPlatformByUrlHint('http://127.0.0.1:8317/v1/models')).toBe('cliproxyapi');
expect(detectPlatformByUrlHint('https://evil.example.com/?next=https://api.openai.com/v1/models')).toBeUndefined();
}); });
}); });
+1
View File
@@ -8,5 +8,6 @@ describe('token route contract', () => {
expect(normalizeTokenRouteMode('pattern')).toBe('pattern'); expect(normalizeTokenRouteMode('pattern')).toBe('pattern');
expect(normalizeTokenRouteMode('anything-else')).toBe('pattern'); expect(normalizeTokenRouteMode('anything-else')).toBe('pattern');
expect(normalizeTokenRouteMode(null)).toBe('pattern'); expect(normalizeTokenRouteMode(null)).toBe('pattern');
expect(normalizeTokenRouteMode(undefined)).toBe('pattern');
}); });
}); });
+4 -1
View File
@@ -1,7 +1,10 @@
export type TokenRoutePatternMatcher = {
test(value: string): boolean;
};
export declare function isTokenRouteRegexPattern(pattern: string): boolean; export declare function isTokenRouteRegexPattern(pattern: string): boolean;
export declare function isExactTokenRouteModelPattern(pattern: string): boolean; export declare function isExactTokenRouteModelPattern(pattern: string): boolean;
export declare function parseTokenRouteRegexPattern(pattern: string): { export declare function parseTokenRouteRegexPattern(pattern: string): {
regex: RegExp | null; regex: TokenRoutePatternMatcher | null;
error: string | null; error: string | null;
}; };
export declare function matchesTokenRouteModelPattern(model: string, pattern: string): boolean; export declare function matchesTokenRouteModelPattern(model: string, pattern: string): boolean;
+493 -169
View File
@@ -1,190 +1,514 @@
let nextNodeId = 1;
function createNode(type, fields = {}) {
return {
id: nextNodeId++,
type,
...fields,
};
}
function isDigitCharacter(ch) {
if (!ch) return false;
const code = ch.charCodeAt(0);
return code >= 48 && code <= 57;
}
function readRegexQuantifierLength(pattern, startIndex) { function readRegexQuantifierLength(pattern, startIndex) {
const ch = pattern[startIndex]; const ch = pattern[startIndex];
if (ch === '*' || ch === '+' || ch === '?') if (ch === '*' || ch === '+' || ch === '?') return 1;
return 1; if (ch !== '{') return 0;
if (ch !== '{')
return 0; let index = startIndex + 1;
let index = startIndex + 1; let sawDigit = false;
let sawDigit = false; while (index < pattern.length && isDigitCharacter(pattern[index])) {
while (index < pattern.length && /\d/.test(pattern[index])) { sawDigit = true;
sawDigit = true; index += 1;
index += 1; }
if (!sawDigit) return 0;
if (pattern[index] === ',') {
index += 1;
while (index < pattern.length && isDigitCharacter(pattern[index])) {
index += 1;
} }
if (!sawDigit) }
return 0; if (pattern[index] !== '}') return 0;
if (pattern[index] === ',') { return index - startIndex + 1;
index += 1;
while (index < pattern.length && /\d/.test(pattern[index])) {
index += 1;
}
}
if (pattern[index] !== '}')
return 0;
return index - startIndex + 1;
} }
function isAllowedSafeRegexCharacter(ch) {
if (!ch) return false;
const code = ch.charCodeAt(0);
const isLowerAlpha = code >= 97 && code <= 122;
const isUpperAlpha = code >= 65 && code <= 90;
const isDigit = code >= 48 && code <= 57;
if (isLowerAlpha || isUpperAlpha || isDigit) return true;
return ' .^$|()[]{}+*?\\:_/-'.includes(ch);
}
function hasUnsafeRegexBackreference(body) {
for (let index = 0; index < body.length; index += 1) {
if (body[index] !== '\\') continue;
const prev = index > 0 ? body[index - 1] : '';
const next = body[index + 1];
if (prev !== '\\' && isDigitCharacter(next) && next !== '0') {
return true;
}
}
return false;
}
function isSafeRegexPatternBody(body) { function isSafeRegexPatternBody(body) {
if (!body || body.length > 256) if (!body || body.length > 256) return false;
return false; for (const ch of body) {
if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) if (!isAllowedSafeRegexCharacter(ch)) return false;
return false; }
if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(?<!') || body.includes('(?<')) { if (
return false; body.includes('(?=')
|| body.includes('(?!')
|| body.includes('(?<=')
|| body.includes('(?<!')
|| body.includes('(?<')
) {
return false;
}
if (hasUnsafeRegexBackreference(body)) {
return false;
}
const groupStack = [];
let escaped = false;
let inCharClass = false;
for (let index = 0; index < body.length; index += 1) {
const ch = body[index];
if (escaped) {
escaped = false;
continue;
} }
if (/(^|[^\\])\\[1-9]/.test(body)) { 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; return false;
}
const parent = groupStack[groupStack.length - 1];
if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) {
parent.hasInnerQuantifier = true;
}
continue;
} }
const groupStack = []; const quantifierLength = readRegexQuantifierLength(body, index);
let escaped = false; if (quantifierLength > 0) {
let inCharClass = false; if (groupStack.length > 0) {
for (let index = 0; index < body.length; index += 1) { groupStack[groupStack.length - 1].hasInnerQuantifier = true;
const ch = body[index]; }
if (escaped) { index += quantifierLength - 1;
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (inCharClass) {
if (ch === ']')
inCharClass = false;
continue;
}
if (ch === '[') {
inCharClass = true;
continue;
}
if (ch === '(') {
if (body[index + 1] === '?') {
if (body[index + 2] !== ':')
return false;
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
index += 2;
continue;
}
groupStack.push({ hasInnerQuantifier: false, hasAlternation: false });
continue;
}
if (ch === '|') {
if (groupStack.length > 0) {
groupStack[groupStack.length - 1].hasAlternation = true;
}
continue;
}
if (ch === ')') {
const group = groupStack.pop();
if (!group)
return false;
const quantifierLength = readRegexQuantifierLength(body, index + 1);
if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) {
return false;
}
const parent = groupStack[groupStack.length - 1];
if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) {
parent.hasInnerQuantifier = true;
}
continue;
}
const quantifierLength = readRegexQuantifierLength(body, index);
if (quantifierLength > 0) {
if (groupStack.length > 0) {
groupStack[groupStack.length - 1].hasInnerQuantifier = true;
}
index += quantifierLength - 1;
}
} }
return !escaped && !inCharClass && groupStack.length === 0; }
return !escaped && !inCharClass && groupStack.length === 0;
} }
function globToRegexSource(glob) {
let source = ''; function matchesGlobPattern(model, pattern) {
for (let i = 0; i < glob.length; i += 1) { let modelIndex = 0;
const ch = glob[i]; let patternIndex = 0;
if (ch === '*') { let starIndex = -1;
source += '.*'; let matchIndex = 0;
continue;
} while (modelIndex < model.length) {
if (ch === '?') { const patternChar = pattern[patternIndex];
source += '.'; const modelChar = model[modelIndex];
continue; if (patternChar === '*' ) {
} starIndex = patternIndex;
if (ch === '[') { matchIndex = modelIndex;
const closeIndex = glob.indexOf(']', i + 1); patternIndex += 1;
if (closeIndex > i + 1) { continue;
source += glob.slice(i, closeIndex + 1);
i = closeIndex;
continue;
}
source += '\\[';
continue;
}
source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&');
} }
return source; if (patternChar === '?' || patternChar === modelChar) {
patternIndex += 1;
modelIndex += 1;
continue;
}
if (starIndex === -1) {
return false;
}
patternIndex = starIndex + 1;
matchIndex += 1;
modelIndex = matchIndex;
}
while (pattern[patternIndex] === '*') {
patternIndex += 1;
}
return patternIndex === pattern.length;
} }
const compiledGlobCache = new Map();
function toArraySet(values) {
const unique = [];
for (const value of values) {
if (!unique.includes(value)) unique.push(value);
}
return unique;
}
function parseCharClassChar(body, state) {
const ch = body[state.index];
if (ch === '\\') {
state.index += 1;
const escaped = body[state.index];
if (!escaped) throw new Error('invalid escape');
state.index += 1;
if (escaped === 'd') return { kind: 'digit' };
return { kind: 'char', value: escaped };
}
if (!ch) throw new Error('invalid char class');
state.index += 1;
return { kind: 'char', value: ch };
}
function parseCharClass(body, state) {
state.index += 1;
let negated = false;
if (body[state.index] === '^') {
negated = true;
state.index += 1;
}
const entries = [];
while (state.index < body.length && body[state.index] !== ']') {
const start = parseCharClassChar(body, state);
if (body[state.index] === '-' && body[state.index + 1] && body[state.index + 1] !== ']') {
state.index += 1;
const end = parseCharClassChar(body, state);
entries.push({ kind: 'range', start, end });
continue;
}
entries.push(start);
}
if (body[state.index] !== ']' || entries.length === 0) {
throw new Error('invalid character class');
}
state.index += 1;
return createNode('charclass', { negated, entries });
}
function parseAtom(body, state) {
const ch = body[state.index];
if (!ch) throw new Error('unexpected end');
if (ch === '(') {
state.index += 1;
const group = parseExpression(body, state, ')');
if (body[state.index] !== ')') {
throw new Error('missing )');
}
state.index += 1;
return group;
}
if (ch === '[') {
return parseCharClass(body, state);
}
if (ch === '.') {
state.index += 1;
return createNode('any');
}
if (ch === '\\') {
state.index += 1;
const escaped = body[state.index];
if (!escaped) throw new Error('invalid escape');
state.index += 1;
if (escaped === 'd') {
return createNode('digit');
}
return createNode('literal', { value: escaped });
}
state.index += 1;
return createNode('literal', { value: ch });
}
function parseQuantifier(body, state) {
const ch = body[state.index];
if (ch === '*') {
state.index += 1;
return { min: 0, max: Infinity };
}
if (ch === '+') {
state.index += 1;
return { min: 1, max: Infinity };
}
if (ch === '?') {
state.index += 1;
return { min: 0, max: 1 };
}
if (ch !== '{') return null;
let index = state.index + 1;
let minText = '';
while (index < body.length && isDigitCharacter(body[index])) {
minText += body[index];
index += 1;
}
if (!minText) return null;
let max = Number.parseInt(minText, 10);
if (body[index] === ',') {
index += 1;
let maxText = '';
while (index < body.length && isDigitCharacter(body[index])) {
maxText += body[index];
index += 1;
}
max = maxText ? Number.parseInt(maxText, 10) : Infinity;
}
if (body[index] !== '}') return null;
state.index = index + 1;
return {
min: Number.parseInt(minText, 10),
max,
};
}
function parseTerm(body, state) {
const atom = parseAtom(body, state);
const quantifier = parseQuantifier(body, state);
if (!quantifier) return atom;
return createNode('repeat', {
atom,
min: quantifier.min,
max: quantifier.max,
});
}
function parseSequence(body, state, stopChar) {
const terms = [];
while (state.index < body.length) {
const ch = body[state.index];
if (ch === '|' || ch === stopChar) break;
terms.push(parseTerm(body, state));
}
if (terms.length === 0) {
return createNode('empty');
}
if (terms.length === 1) return terms[0];
return createNode('sequence', { terms });
}
function parseExpression(body, state, stopChar = '') {
const branches = [parseSequence(body, state, stopChar)];
while (state.index < body.length && body[state.index] === '|') {
state.index += 1;
branches.push(parseSequence(body, state, stopChar));
}
if (branches.length === 1) return branches[0];
return createNode('alternation', { branches });
}
function charMatchesCharClassEntry(entry, ch) {
if (entry.kind === 'digit') return isDigitCharacter(ch);
if (entry.kind === 'char') return ch === entry.value;
if (entry.kind === 'range') {
if (entry.start.kind !== 'char' || entry.end.kind !== 'char') {
return false;
}
const code = ch.charCodeAt(0);
return code >= entry.start.value.charCodeAt(0) && code <= entry.end.value.charCodeAt(0);
}
return false;
}
function matchNode(node, value, position, cache) {
const cacheKey = `${node.id}:${position}`;
const cached = cache.get(cacheKey);
if (cache.has(cacheKey)) return cached;
let result = [];
switch (node.type) {
case 'empty':
result = [position];
break;
case 'literal':
result = value.startsWith(node.value, position) ? [position + node.value.length] : [];
break;
case 'any':
result = position < value.length ? [position + 1] : [];
break;
case 'digit':
result = position < value.length && isDigitCharacter(value[position]) ? [position + 1] : [];
break;
case 'charclass': {
if (position < value.length) {
const matched = node.entries.some((entry) => charMatchesCharClassEntry(entry, value[position]));
if ((matched && !node.negated) || (!matched && node.negated)) {
result = [position + 1];
}
}
break;
}
case 'sequence': {
let positions = [position];
for (const term of node.terms) {
const nextPositions = [];
for (const current of positions) {
nextPositions.push(...matchNode(term, value, current, cache));
}
positions = toArraySet(nextPositions);
if (positions.length === 0) break;
}
result = positions;
break;
}
case 'alternation': {
const positions = [];
for (const branch of node.branches) {
positions.push(...matchNode(branch, value, position, cache));
}
result = toArraySet(positions);
break;
}
case 'repeat': {
let results = node.min === 0 ? [position] : [];
let frontier = [position];
const maxRepeat = Number.isFinite(node.max) ? node.max : (value.length - position + 1);
for (let count = 1; count <= maxRepeat; count += 1) {
const nextPositions = [];
for (const current of frontier) {
const ends = matchNode(node.atom, value, current, cache);
for (const end of ends) {
if (end !== current) nextPositions.push(end);
}
}
frontier = toArraySet(nextPositions);
if (frontier.length === 0) break;
if (count >= node.min) {
results = toArraySet([...results, ...frontier]);
}
}
result = results;
break;
}
default:
result = [];
break;
}
cache.set(cacheKey, result);
return result;
}
function compileSafeRegexPattern(body) {
nextNodeId = 1;
const anchoredStart = body.startsWith('^');
const anchoredEnd = body.endsWith('$') && body[body.length - 2] !== '\\';
const normalizedBody = body
.slice(anchoredStart ? 1 : 0, anchoredEnd ? -1 : body.length)
.trim();
const state = { index: 0 };
const root = parseExpression(normalizedBody, state);
if (state.index !== normalizedBody.length) {
throw new Error('unsupported regex syntax');
}
return {
test(value) {
const starts = anchoredStart
? [0]
: Array.from({ length: value.length + 1 }, (_, index) => index);
for (const start of starts) {
const ends = matchNode(root, value, start, new Map());
if (!anchoredEnd && ends.length > 0) return true;
if (anchoredEnd && ends.includes(value.length)) return true;
}
return false;
},
};
}
const matchCache = new Map(); const matchCache = new Map();
const MATCH_CACHE_LIMIT = 4000; const MATCH_CACHE_LIMIT = 4000;
function matchesGlobPattern(model, pattern) {
let re = compiledGlobCache.get(pattern);
if (re === undefined) {
try {
re = new RegExp(`^${globToRegexSource(pattern)}$`);
}
catch {
re = null;
}
compiledGlobCache.set(pattern, re);
}
return re ? re.test(model) : false;
}
export function isTokenRouteRegexPattern(pattern) { export function isTokenRouteRegexPattern(pattern) {
return pattern.trim().toLowerCase().startsWith('re:'); return pattern.trim().toLowerCase().startsWith('re:');
} }
export function isExactTokenRouteModelPattern(pattern) { export function isExactTokenRouteModelPattern(pattern) {
const normalized = pattern.trim(); const normalized = pattern.trim();
if (!normalized) if (!normalized) return false;
return false; if (isTokenRouteRegexPattern(normalized)) return false;
if (isTokenRouteRegexPattern(normalized)) return !/[\*\?]/.test(normalized);
return false;
return !/[\*\?]/.test(normalized);
} }
export function parseTokenRouteRegexPattern(pattern) { export function parseTokenRouteRegexPattern(pattern) {
if (!isTokenRouteRegexPattern(pattern)) if (!isTokenRouteRegexPattern(pattern)) {
return { regex: null, error: null }; return { regex: null, error: null };
const body = pattern.trim().slice(3).trim(); }
if (!body) const body = pattern.trim().slice(3).trim();
return { regex: null, error: 're: 后缺少正则表达式' }; if (!body) {
if (!isSafeRegexPatternBody(body)) { return { regex: null, error: 're: 后缺少正则表达式' };
return { regex: null, error: '出于安全原因不支持该正则表达式' }; }
} if (!isSafeRegexPatternBody(body)) {
try { return { regex: null, error: '出于安全原因不支持该正则表达式' };
return { regex: new RegExp(body), error: null }; }
} try {
catch (error) { return {
return { regex: null, error: error?.message || '无效正则' }; regex: compileSafeRegexPattern(body),
} error: null,
};
} catch (error) {
return { regex: null, error: error?.message || '无效正则' };
}
} }
export function matchesTokenRouteModelPattern(model, pattern) { export function matchesTokenRouteModelPattern(model, pattern) {
const normalized = (pattern || '').trim(); const normalized = (pattern || '').trim();
if (!normalized) if (!normalized) return false;
return false; if (normalized === model) return true;
if (normalized === model)
return true; const cacheKey = `${model}\0${normalized}`;
const cacheKey = `${model}\0${normalized}`; const cached = matchCache.get(cacheKey);
const cached = matchCache.get(cacheKey); if (cached !== undefined) return cached;
if (cached !== undefined)
return cached; let result;
let result; if (isTokenRouteRegexPattern(normalized)) {
if (isTokenRouteRegexPattern(normalized)) { const parsed = parseTokenRouteRegexPattern(normalized);
const parsed = parseTokenRouteRegexPattern(normalized); result = !!parsed.regex && parsed.regex.test(model);
result = !!parsed.regex && parsed.regex.test(model); } else {
} result = matchesGlobPattern(model, normalized);
else { }
result = matchesGlobPattern(model, normalized);
} if (matchCache.size >= MATCH_CACHE_LIMIT) {
if (matchCache.size >= MATCH_CACHE_LIMIT) matchCache.clear();
matchCache.clear(); }
matchCache.set(cacheKey, result); matchCache.set(cacheKey, result);
return result; return result;
} }
+9 -3
View File
@@ -2,8 +2,13 @@ import { describe, expect, it } from 'vitest';
describe('token route pattern helpers', () => { describe('token route pattern helpers', () => {
it('treats bracket-prefixed literal model names as exact patterns', async () => { it('treats bracket-prefixed literal model names as exact patterns', async () => {
const { isExactTokenRouteModelPattern } = await import('./tokenRoutePatterns.js'); const {
isExactTokenRouteModelPattern,
matchesTokenRouteModelPattern,
} = await import('./tokenRoutePatterns.js');
expect(isExactTokenRouteModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true); expect(isExactTokenRouteModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true);
expect(matchesTokenRouteModelPattern('[NV]deepseek-v3.1-terminus', '[NV]deepseek-v3.1-terminus')).toBe(true);
expect(matchesTokenRouteModelPattern('Ndeepseek-v3.1-terminus', '[NV]deepseek-v3.1-terminus')).toBe(false);
}); });
it('rejects unsafe nested-quantifier regex patterns', async () => { it('rejects unsafe nested-quantifier regex patterns', async () => {
@@ -12,8 +17,8 @@ describe('token route pattern helpers', () => {
parseTokenRouteRegexPattern, parseTokenRouteRegexPattern,
} = await import('./tokenRoutePatterns.js'); } = await import('./tokenRoutePatterns.js');
expect(parseTokenRouteRegexPattern('re:^(a+)+$').regex).toBeNull(); expect(parseTokenRouteRegexPattern('re:(?=claude)').regex).toBeNull();
expect(matchesTokenRouteModelPattern('aaaa', 're:^(a+)+$')).toBe(false); expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false);
}); });
it('supports exact, glob, and safe regex route matches', async () => { it('supports exact, glob, and safe regex route matches', async () => {
@@ -22,5 +27,6 @@ describe('token route pattern helpers', () => {
expect(matchesTokenRouteModelPattern('gpt-4o-mini', 'gpt-4o-mini')).toBe(true); expect(matchesTokenRouteModelPattern('gpt-4o-mini', 'gpt-4o-mini')).toBe(true);
expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 'claude-*')).toBe(true); expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 'claude-*')).toBe(true);
expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:^claude-(opus|sonnet)-4-6$')).toBe(true); expect(matchesTokenRouteModelPattern('claude-sonnet-4-6', 're:^claude-(opus|sonnet)-4-6$')).toBe(true);
expect(matchesTokenRouteModelPattern('gpt-4o-mini-2025', 're:^gpt-4o-mini-\\d+$')).toBe(true);
}); });
}); });
+6 -1
View File
@@ -208,7 +208,7 @@ async function proxyTestStreamRequest(data: ProxyTestRequestEnvelope, signal?: A
clearAuthSession(localStorage); clearAuthSession(localStorage);
throw new Error('Session expired'); throw new Error('Session expired');
} }
return fetch('/api/test/proxy/stream', { const response = await fetch('/api/test/proxy/stream', {
method: 'POST', method: 'POST',
signal, signal,
headers: { headers: {
@@ -217,6 +217,11 @@ async function proxyTestStreamRequest(data: ProxyTestRequestEnvelope, signal?: A
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (response.status === 401 || response.status === 403) {
clearAuthSession(localStorage);
throw new Error('Session expired');
}
return response;
} }
export type ProxyTestJobResponse = { export type ProxyTestJobResponse = {
@@ -10,6 +10,11 @@ describe('accountConnection helpers', () => {
expect(resolveAccountCredentialMode({ credentialMode: 'apikey' })).toBe('apikey'); expect(resolveAccountCredentialMode({ credentialMode: 'apikey' })).toBe('apikey');
expect(resolveAccountCredentialMode({ capabilities: { proxyOnly: true } })).toBe('apikey'); expect(resolveAccountCredentialMode({ capabilities: { proxyOnly: true } })).toBe('apikey');
expect(resolveAccountCredentialMode({ accessToken: ' session-token ' })).toBe('session'); expect(resolveAccountCredentialMode({ accessToken: ' session-token ' })).toBe('session');
expect(resolveAccountCredentialMode({
credentialMode: 'session',
capabilities: { proxyOnly: true },
accessToken: 'api-token-ignored-by-explicit-mode',
})).toBe('session');
expect(resolveAccountCredentialMode({})).toBe('apikey'); expect(resolveAccountCredentialMode({})).toBe('apikey');
}); });
@@ -17,6 +22,9 @@ describe('accountConnection helpers', () => {
expect(parsePositiveInt('42')).toBe(42); expect(parsePositiveInt('42')).toBe(42);
expect(parsePositiveInt(' 0 ')).toBe(0); expect(parsePositiveInt(' 0 ')).toBe(0);
expect(parsePositiveInt('abc')).toBe(0); expect(parsePositiveInt('abc')).toBe(0);
expect(parsePositiveInt('42abc')).toBe(0);
expect(parsePositiveInt('1e3')).toBe(0);
expect(parsePositiveInt('1.5')).toBe(0);
expect(parsePositiveInt(null)).toBe(0); expect(parsePositiveInt(null)).toBe(0);
}); });
+3 -1
View File
@@ -11,7 +11,9 @@ export function resolveAccountCredentialMode(account: any): 'session' | 'apikey'
} }
export function parsePositiveInt(input: string | null): number { export function parsePositiveInt(input: string | null): number {
const value = Number.parseInt(String(input || '').trim(), 10); const normalized = String(input || '').trim();
if (!/^\d+$/.test(normalized)) return 0;
const value = Number.parseInt(normalized, 10);
if (!Number.isFinite(value) || value <= 0) return 0; if (!Number.isFinite(value) || value <= 0) return 0;
return value; return value;
} }
@@ -75,5 +75,9 @@ describe('resolveConversationFileCapability', () => {
filename: 'voice.mp3', filename: 'voice.mp3',
mimeType: 'audio/mpeg', mimeType: 'audio/mpeg',
})).toBe(false); })).toBe(false);
expect(isConversationUploadedFileSupported(claudeCapability, {
filename: 'paper.pdf',
mimeType: 'application/octet-stream',
})).toBe(true);
}); });
}); });
+1 -1
View File
@@ -65,7 +65,7 @@ export function isRouteExactModel(route: Pick<RouteRow | RouteSummaryRow, 'model
return !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern); return !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern);
} }
export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } { export function parseRegexModelPattern(modelPattern: string): { regex: { test(value: string): boolean } | null; error: string | null } {
return parseTokenRouteRegexPattern(modelPattern); return parseTokenRouteRegexPattern(modelPattern);
} }