fix: address review feedback and code scanning alerts
This commit is contained in:
@@ -71,7 +71,7 @@ describe('ensureProxyFileSchemaCompatibility', () => {
|
||||
'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
|
||||
);
|
||||
expect(executedSql).toContain(
|
||||
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`)',
|
||||
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ const CREATE_INDEX_SQL: Record<ProxyFileSchemaDialect, string[]> = {
|
||||
],
|
||||
mysql: [
|
||||
'CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191))',
|
||||
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`)',
|
||||
'CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(64), `owner_id`(191), `deleted_at`(191))',
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
import { detectCodexOfficialClientApp } from './codexProfile.js';
|
||||
|
||||
describe('detectCodexOfficialClientApp', () => {
|
||||
it('returns null for missing or empty headers', () => {
|
||||
expect(detectCodexOfficialClientApp()).toBeNull();
|
||||
expect(detectCodexOfficialClientApp({})).toBeNull();
|
||||
});
|
||||
|
||||
it('detects official Codex clients from originator prefixes', () => {
|
||||
expect(detectCodexOfficialClientApp({
|
||||
originator: 'codex_exec',
|
||||
@@ -20,6 +25,30 @@ describe('detectCodexOfficialClientApp', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('matches headers case-insensitively and from header arrays', () => {
|
||||
expect(detectCodexOfficialClientApp({
|
||||
originator: 'CODEX_EXEC',
|
||||
})).toEqual({
|
||||
clientAppId: 'codex_exec',
|
||||
clientAppName: 'Codex Exec',
|
||||
});
|
||||
expect(detectCodexOfficialClientApp({
|
||||
'user-agent': 'Mozilla/5.0 CODEX_chatgpt_desktop/1.2',
|
||||
})).toEqual({
|
||||
clientAppId: 'codex_chatgpt_desktop',
|
||||
clientAppName: 'Codex Desktop',
|
||||
});
|
||||
expect(detectCodexOfficialClientApp({
|
||||
'user-agent': ['Mozilla/5.0', 'codex_chatgpt_desktop/1.2'],
|
||||
})).toEqual({
|
||||
clientAppId: 'codex_chatgpt_desktop',
|
||||
clientAppName: 'Codex Desktop',
|
||||
});
|
||||
expect(detectCodexOfficialClientApp({
|
||||
originator: ['ignored', 'other-client'],
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-official Codex clients', () => {
|
||||
expect(detectCodexOfficialClientApp({
|
||||
'user-agent': 'OpenClaw/1.0',
|
||||
|
||||
@@ -66,33 +66,43 @@ const CODEX_OFFICIAL_CLIENT_APP_RULES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
function headerValueToStrings(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const values: string[] = [];
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
if (trimmed) values.push(trimmed);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
return headerValueToStrings(value)[0] || null;
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Record<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 values: string[] = [];
|
||||
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
|
||||
return headerValueToString(rawValue);
|
||||
values.push(...headerValueToStrings(rawValue));
|
||||
}
|
||||
|
||||
return null;
|
||||
return values;
|
||||
}
|
||||
|
||||
function hasHeaderPrefix(headers: Record<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 {
|
||||
const normalizedValue = value?.trim().toLowerCase() || '';
|
||||
if (!normalizedValue) return false;
|
||||
function matchesHeaderPrefixes(value: string | string[] | null, prefixes: readonly string[]): boolean {
|
||||
const values = Array.isArray(value)
|
||||
? value.map((item) => item.trim().toLowerCase()).filter(Boolean)
|
||||
: [value?.trim().toLowerCase() || ''].filter(Boolean);
|
||||
if (values.length === 0) return false;
|
||||
|
||||
return prefixes.some((prefix) => {
|
||||
return values.some((normalizedValue) => prefixes.some((prefix) => {
|
||||
const normalizedPrefix = prefix.trim().toLowerCase();
|
||||
if (!normalizedPrefix) return false;
|
||||
return normalizedValue.startsWith(normalizedPrefix)
|
||||
|| normalizedValue.includes(normalizedPrefix);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
function isCodexPath(path: string): boolean {
|
||||
@@ -127,10 +139,8 @@ export function detectCodexOfficialClientApp(
|
||||
headers?: Record<string, unknown>,
|
||||
): CodexOfficialClientApp | null {
|
||||
for (const rule of CODEX_OFFICIAL_CLIENT_APP_RULES) {
|
||||
const matchesOriginator = Array.isArray(rule.originatorPrefixes)
|
||||
&& matchesHeaderPrefixes(getHeaderValue(headers, 'originator'), [...rule.originatorPrefixes]);
|
||||
const matchesUserAgent = Array.isArray(rule.userAgentPrefixes)
|
||||
&& matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), [...rule.userAgentPrefixes]);
|
||||
const matchesOriginator = matchesHeaderPrefixes(getHeaderValues(headers, 'originator'), rule.originatorPrefixes);
|
||||
const matchesUserAgent = matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), rule.userAgentPrefixes);
|
||||
if (!matchesOriginator && !matchesUserAgent) continue;
|
||||
return {
|
||||
clientAppId: rule.id,
|
||||
@@ -156,9 +166,9 @@ export function isCodexRequest(input: DetectCliProfileInput): boolean {
|
||||
const headers = input.headers;
|
||||
if (!headers) return false;
|
||||
|
||||
const originator = getHeaderValue(headers, 'originator');
|
||||
const originator = getHeaderValues(headers, 'originator');
|
||||
if (matchesHeaderPrefixes(originator, CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES)) return true;
|
||||
if (matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true;
|
||||
if (matchesHeaderPrefixes(getHeaderValues(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true;
|
||||
if (getHeaderValue(headers, 'openai-beta')) return true;
|
||||
if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
|
||||
if (getCodexSessionId(headers)) return true;
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('provider header utils', () => {
|
||||
|
||||
const headers = buildGeminiCliRuntimeHeaders({
|
||||
baseHeaders: {
|
||||
Authorization: 'Bearer test',
|
||||
authorization: 'Bearer test',
|
||||
},
|
||||
providerHeaders: {
|
||||
'x-goog-api-client': 'gl-node/22 gccl/0.31.0',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { pbkdf2Sync, randomUUID } from 'node:crypto';
|
||||
|
||||
const CODEX_CLIENT_VERSION = '0.101.0';
|
||||
const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
|
||||
@@ -33,8 +33,8 @@ export function getInputHeader(
|
||||
}
|
||||
|
||||
export function uuidFromSeed(seed: string): string {
|
||||
const hash = createHash('sha1').update(seed).digest();
|
||||
const bytes = new Uint8Array(hash.subarray(0, 16));
|
||||
const derived = pbkdf2Sync(seed, 'metapi-runtime-header-seed', 10_000, 16, 'sha256');
|
||||
const bytes = new Uint8Array(derived);
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
||||
@@ -153,7 +153,7 @@ export function buildCodexRuntimeHeaders(input: {
|
||||
);
|
||||
|
||||
return {
|
||||
Authorization: authorization,
|
||||
...(authorization ? { Authorization: authorization } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
|
||||
Originator: originator,
|
||||
@@ -221,6 +221,7 @@ export function buildGeminiCliRuntimeHeaders(input: {
|
||||
modelName: string;
|
||||
stream: boolean;
|
||||
}): Record<string, string> {
|
||||
const authorization = getInputHeader(input.baseHeaders, 'authorization');
|
||||
const apiClient = (
|
||||
getInputHeader(input.providerHeaders, 'x-goog-api-client')
|
||||
|| getInputHeader(input.baseHeaders, 'x-goog-api-client')
|
||||
@@ -231,7 +232,7 @@ export function buildGeminiCliRuntimeHeaders(input: {
|
||||
);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: input.baseHeaders.Authorization,
|
||||
...(authorization ? { Authorization: authorization } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
};
|
||||
|
||||
@@ -808,7 +808,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(failureOutcome.status).type(contentType).send(payload);
|
||||
return reply.code(failureOutcome.status).send(failureOutcome.payload);
|
||||
}
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
softDeleteProxyFileByPublicIdForOwner,
|
||||
} from '../../services/proxyFileStore.js';
|
||||
import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js';
|
||||
import { isSupportedConversationFileMimeType } from '../../../shared/conversationFileTypes.js';
|
||||
import {
|
||||
isSupportedConversationFileMimeType,
|
||||
resolveConversationFileMimeType,
|
||||
} from '../../../shared/conversationFileTypes.js';
|
||||
|
||||
function invalidRequest(reply: FastifyReply, message: string) {
|
||||
return reply.code(400).send({ error: { message, type: 'invalid_request_error' } });
|
||||
@@ -44,21 +47,6 @@ function toFileObject(record: {
|
||||
};
|
||||
}
|
||||
|
||||
function inferMimeTypeFromFilename(filename: string): string {
|
||||
const normalized = filename.trim().toLowerCase();
|
||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
||||
if (normalized.endsWith('.txt')) return 'text/plain';
|
||||
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
|
||||
if (normalized.endsWith('.json')) return 'application/json';
|
||||
if (normalized.endsWith('.png')) return 'image/png';
|
||||
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (normalized.endsWith('.gif')) return 'image/gif';
|
||||
if (normalized.endsWith('.webp')) return 'image/webp';
|
||||
if (normalized.endsWith('.wav')) return 'audio/wav';
|
||||
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
export async function filesProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
|
||||
@@ -79,7 +67,7 @@ export async function filesProxyRoute(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const filename = fileEntry.name || 'upload.bin';
|
||||
const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase();
|
||||
const mimeType = resolveConversationFileMimeType(fileEntry.type, filename);
|
||||
if (!isSupportedConversationFileMimeType(mimeType)) {
|
||||
return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`);
|
||||
}
|
||||
|
||||
@@ -715,46 +715,50 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
usage: parsedUsage,
|
||||
serializationMode: isCompactRequest ? 'compact' : 'response',
|
||||
});
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
tokenValue: selected.tokenValue,
|
||||
tokenName: selected.tokenName,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
requestStartedAtMs: startTime,
|
||||
requestEndedAtMs: startTime + latency,
|
||||
localLatencyMs: latency,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
try {
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
tokenValue: selected.tokenValue,
|
||||
tokenName: selected.tokenName,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
requestStartedAtMs: startTime,
|
||||
requestEndedAtMs: startTime + latency,
|
||||
localLatencyMs: latency,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
modelRequested: requestedModel,
|
||||
status: 'success',
|
||||
httpStatus: 200,
|
||||
latencyMs: latency,
|
||||
errorMessage: null,
|
||||
retryCount,
|
||||
promptTokens: resolvedUsage.promptTokens,
|
||||
completionTokens: resolvedUsage.completionTokens,
|
||||
totalTokens: resolvedUsage.totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
await failureToolkit.log({
|
||||
selected,
|
||||
modelRequested: requestedModel,
|
||||
status: 'success',
|
||||
httpStatus: 200,
|
||||
latencyMs: latency,
|
||||
errorMessage: null,
|
||||
retryCount,
|
||||
promptTokens: resolvedUsage.promptTokens,
|
||||
completionTokens: resolvedUsage.completionTokens,
|
||||
totalTokens: resolvedUsage.totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails,
|
||||
upstreamPath: successfulUpstreamPath,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[responses] post-response bookkeeping failed:', error);
|
||||
}
|
||||
return reply.send(downstreamData);
|
||||
} catch (err: any) {
|
||||
const failureOutcome = await failureToolkit.handleExecutionError({
|
||||
|
||||
@@ -206,7 +206,7 @@ export function createSurfaceFailureToolkit(input: {
|
||||
retryCount: number;
|
||||
}): Promise<SurfaceFailureOutcome> {
|
||||
const rawErrText = args.rawErrText || args.errText;
|
||||
tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
await tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
status: args.status,
|
||||
errorText: rawErrText,
|
||||
modelName: args.modelName,
|
||||
@@ -269,7 +269,7 @@ export function createSurfaceFailureToolkit(input: {
|
||||
totalTokens?: number;
|
||||
upstreamPath?: string | null;
|
||||
}): Promise<SurfaceFailureOutcome> {
|
||||
tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
await tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
status: args.failure.status,
|
||||
errorText: args.failure.reason,
|
||||
modelName: args.modelName,
|
||||
@@ -318,7 +318,7 @@ export function createSurfaceFailureToolkit(input: {
|
||||
latencyMs: number;
|
||||
retryCount: number;
|
||||
}): Promise<SurfaceFailureOutcome> {
|
||||
tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
await tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
errorText: args.errorMessage,
|
||||
modelName: args.modelName,
|
||||
});
|
||||
@@ -368,13 +368,13 @@ export function createSurfaceFailureToolkit(input: {
|
||||
}) {
|
||||
const errorMessage = args.errorMessage || 'stream processing failed';
|
||||
if (typeof args.runtimeFailureStatus === 'number') {
|
||||
tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
await tokenRouter.recordFailure(args.selected.channel.id, {
|
||||
status: args.runtimeFailureStatus,
|
||||
errorText: errorMessage,
|
||||
modelName: args.modelName,
|
||||
});
|
||||
} else {
|
||||
tokenRouter.recordFailure(args.selected.channel.id, args.modelName);
|
||||
await tokenRouter.recordFailure(args.selected.channel.id, args.modelName);
|
||||
}
|
||||
await log({
|
||||
selected: args.selected,
|
||||
|
||||
@@ -228,11 +228,39 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
||||
|
||||
if (!row.accounts.accessToken) {
|
||||
if (row.accounts.apiToken) {
|
||||
await convergeAccountMutation({
|
||||
accountId,
|
||||
preferredApiToken: row.accounts.apiToken,
|
||||
defaultTokenSource: 'legacy',
|
||||
});
|
||||
try {
|
||||
const convergence = await convergeAccountMutation({
|
||||
accountId,
|
||||
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 {
|
||||
...base,
|
||||
|
||||
@@ -242,12 +242,12 @@ function detectProtocolClientApp(input: {
|
||||
};
|
||||
case 'codex': {
|
||||
const clientApp = detectCodexOfficialClientAppViaProfile(input.headers);
|
||||
return (clientApp
|
||||
return clientApp
|
||||
? {
|
||||
...clientApp,
|
||||
clientConfidence: 'exact' as const,
|
||||
}
|
||||
: null) || {
|
||||
: {
|
||||
clientAppId: 'codex',
|
||||
clientAppName: 'Codex',
|
||||
clientConfidence: 'heuristic',
|
||||
|
||||
@@ -77,6 +77,16 @@ export async function convergeAccountMutation(input: {
|
||||
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,
|
||||
|
||||
@@ -617,7 +617,11 @@ function normalizeOriginUrl(raw: string): string {
|
||||
function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string {
|
||||
const rawPlatformText = asString(rawPlatform).toLowerCase();
|
||||
const normalizedPlatform = rawPlatformText
|
||||
? (PLATFORM_ALIASES[rawPlatformText] ?? (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : ''))
|
||||
? (
|
||||
Object.prototype.hasOwnProperty.call(PLATFORM_ALIASES, rawPlatformText)
|
||||
? PLATFORM_ALIASES[rawPlatformText]
|
||||
: (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '')
|
||||
)
|
||||
: '';
|
||||
if (normalizedPlatform) return normalizedPlatform;
|
||||
|
||||
|
||||
@@ -516,15 +516,16 @@ export async function listOauthConnections(options: {
|
||||
routeChannelCountByAccount.set(row.accountId, row.count ?? 0);
|
||||
}
|
||||
|
||||
const items = rows.map((row) => {
|
||||
const oauth = getOauthInfoFromAccount(row.accounts)!;
|
||||
const items = rows.flatMap((row) => {
|
||||
const oauth = getOauthInfoFromAccount(row.accounts);
|
||||
if (!oauth) return [];
|
||||
const models = modelMap.get(row.accounts.id) || [];
|
||||
const status = (
|
||||
oauth.modelDiscoveryStatus === 'abnormal'
|
||||
|| row.accounts.status !== 'active'
|
||||
|| row.sites.status !== 'active'
|
||||
) ? 'abnormal' : 'healthy';
|
||||
return {
|
||||
return [{
|
||||
accountId: row.accounts.id,
|
||||
siteId: row.sites.id,
|
||||
provider: oauth.provider,
|
||||
@@ -546,7 +547,7 @@ export async function listOauthConnections(options: {
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
},
|
||||
};
|
||||
}];
|
||||
});
|
||||
|
||||
return { items, total, limit, offset };
|
||||
|
||||
@@ -13,7 +13,11 @@ type FetchModelsOptions = {
|
||||
};
|
||||
|
||||
export function normalizePlatformBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
let normalized = baseUrl || '';
|
||||
while (normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveVersionedModelsUrl(baseUrl: string): string {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ProxyResourceOwner } from '../middleware/auth.js';
|
||||
import { getProxyFileByPublicIdForOwner } from './proxyFileStore.js';
|
||||
import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
|
||||
import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js';
|
||||
import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js';
|
||||
import { isSupportedConversationFileMimeType } from '../../shared/conversationFileTypes.js';
|
||||
|
||||
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
|
||||
import {
|
||||
isSupportedConversationFileMimeType,
|
||||
resolveConversationFileMimeType,
|
||||
} from '../../shared/conversationFileTypes.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -26,24 +27,6 @@ function cloneJsonValue<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
function inferMimeTypeFromFilename(filename: string): string {
|
||||
const normalized = filename.toLowerCase();
|
||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
||||
if (normalized.endsWith('.txt')) return 'text/plain';
|
||||
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
|
||||
if (normalized.endsWith('.json')) return 'application/json';
|
||||
if (normalized.endsWith('.png')) return 'image/png';
|
||||
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (normalized.endsWith('.gif')) return 'image/gif';
|
||||
if (normalized.endsWith('.webp')) return 'image/webp';
|
||||
if (normalized.endsWith('.wav')) return 'audio/wav';
|
||||
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
|
||||
if (normalized.endsWith('.m4a')) return 'audio/mp4';
|
||||
if (normalized.endsWith('.ogg')) return 'audio/ogg';
|
||||
if (normalized.endsWith('.webm')) return 'audio/webm';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isSupportedMimeType(mimeType: string): boolean {
|
||||
return isSupportedConversationFileMimeType(mimeType);
|
||||
}
|
||||
@@ -116,7 +99,7 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc
|
||||
}> {
|
||||
if (fileLike.fileData) {
|
||||
const filename = fileLike.filename || 'upload.bin';
|
||||
const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename);
|
||||
const mimeType = resolveConversationFileMimeType(fileLike.mimeType, filename);
|
||||
if (!isSupportedMimeType(mimeType)) {
|
||||
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
|
||||
}
|
||||
@@ -133,7 +116,10 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc
|
||||
if (!stored) {
|
||||
throw new ProxyInputFileResolutionError(404, `file not found: ${fileLike.fileId}`, 'not_found_error');
|
||||
}
|
||||
const mimeType = fileLike.mimeType || stored.mimeType || inferMimeTypeFromFilename(stored.filename);
|
||||
const mimeType = resolveConversationFileMimeType(
|
||||
fileLike.mimeType || stored.mimeType,
|
||||
stored.filename,
|
||||
);
|
||||
if (!isSupportedMimeType(mimeType)) {
|
||||
throw new ProxyInputFileResolutionError(400, `unsupported file mime type: ${mimeType}`);
|
||||
}
|
||||
@@ -145,7 +131,7 @@ async function resolveInputFileLike(fileLike: InputFileLike, owner: ProxyResourc
|
||||
}
|
||||
|
||||
const filename = fileLike.filename || 'upload.bin';
|
||||
const mimeType = fileLike.mimeType || inferMimeTypeFromFilename(filename);
|
||||
const mimeType = resolveConversationFileMimeType(fileLike.mimeType, filename);
|
||||
if (!fileLike.fileData) {
|
||||
throw new ProxyInputFileResolutionError(400, `file_data is required for inline file block: ${filename}`);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ type Candidate = {
|
||||
|
||||
describe('filterRecentlyFailedCandidates', () => {
|
||||
it('rejects unsafe nested-quantifier regex route patterns', () => {
|
||||
expect(parseRegexModelPattern('re:^(a+)+$')).toBeNull();
|
||||
expect(matchesModelPattern('aaaa', 're:^(a+)+$')).toBe(false);
|
||||
expect(parseRegexModelPattern('re:(?=claude)')).toBeNull();
|
||||
expect(matchesModelPattern('claude-sonnet-4-6', 're:(?=claude)')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses a short default recent-failure window', () => {
|
||||
|
||||
@@ -894,7 +894,7 @@ export function isRegexModelPattern(pattern: string): boolean {
|
||||
return isTokenRouteRegexPattern(pattern);
|
||||
}
|
||||
|
||||
export function parseRegexModelPattern(pattern: string): RegExp | null {
|
||||
export function parseRegexModelPattern(pattern: string): { test(value: string): boolean } | null {
|
||||
return parseTokenRouteRegexPattern(pattern).regex;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,24 @@ describe('geminiGenerateContentTransformer.inbound', () => {
|
||||
expect(geminiGenerateContentOutbound.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl);
|
||||
});
|
||||
|
||||
it('preserves base-url query params when resolving Gemini endpoints', () => {
|
||||
expect(
|
||||
resolveGeminiNativeBaseUrl('https://example.com/native?alt=sse', 'v1beta'),
|
||||
).toBe('https://example.com/native/v1beta?alt=sse');
|
||||
expect(
|
||||
resolveGeminiModelsUrl('https://example.com/native?alt=sse', 'v1beta', 'api-key'),
|
||||
).toBe('https://example.com/native/v1beta/models?alt=sse&key=api-key');
|
||||
expect(
|
||||
resolveGeminiGenerateContentUrl(
|
||||
'https://example.com/native?alt=sse',
|
||||
'v1beta',
|
||||
'/models/gemini-2.5-pro:generateContent',
|
||||
'api-key',
|
||||
'?trace=1',
|
||||
),
|
||||
).toBe('https://example.com/native/v1beta/models/gemini-2.5-pro:generateContent?alt=sse&trace=1&key=api-key');
|
||||
});
|
||||
|
||||
it('parses native Gemini requests into canonical envelopes', () => {
|
||||
const result = geminiGenerateContentTransformer.parseRequest({
|
||||
model: 'gemini-2.5-pro',
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
function normalizePathname(pathname: string): string {
|
||||
let normalized = pathname || '';
|
||||
while (normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function baseIncludesVersion(baseUrl: string): boolean {
|
||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
||||
function splitBaseUrl(baseUrl: string): { path: string; query: string } {
|
||||
const raw = baseUrl || '';
|
||||
const queryIndex = raw.indexOf('?');
|
||||
if (queryIndex < 0) {
|
||||
return { path: normalizePathname(raw), query: '' };
|
||||
}
|
||||
return {
|
||||
path: normalizePathname(raw.slice(0, queryIndex)),
|
||||
query: raw.slice(queryIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function baseIncludesVersion(path: string): boolean {
|
||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(path);
|
||||
}
|
||||
|
||||
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (baseIncludesVersion(normalized)) return normalized;
|
||||
return `${normalized}/${apiVersion}`;
|
||||
const { path, query } = splitBaseUrl(baseUrl);
|
||||
const normalizedPath = baseIncludesVersion(path)
|
||||
? path
|
||||
: `${path}/${apiVersion.replace(/^\/+/, '')}`;
|
||||
return `${normalizedPath}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function resolveGeminiModelsUrl(
|
||||
@@ -18,8 +36,10 @@ export function resolveGeminiModelsUrl(
|
||||
apiKey: string,
|
||||
): string {
|
||||
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
||||
const separator = base.includes('?') ? '&' : '?';
|
||||
return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`;
|
||||
const [path, query = ''] = base.split('?', 2);
|
||||
const params = new URLSearchParams(query);
|
||||
params.set('key', apiKey);
|
||||
return `${path}/models?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function resolveGeminiGenerateContentUrl(
|
||||
@@ -31,8 +51,13 @@ export function resolveGeminiGenerateContentUrl(
|
||||
): string {
|
||||
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||
const [path, baseQuery = ''] = base.split('?', 2);
|
||||
const params = new URLSearchParams(baseQuery);
|
||||
const extraParams = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||
for (const [key, value] of extraParams) {
|
||||
params.set(key, value);
|
||||
}
|
||||
params.set('key', apiKey);
|
||||
const query = params.toString();
|
||||
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`;
|
||||
return `${path}/${normalizedAction}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { normalizeInputFileBlock, toResponsesInputFileBlock } from '../../shared/inputFile.js';
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user