From 32d7037629b280532471511e76bef42f01dfc336 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 22 Mar 2026 22:17:50 +0800 Subject: [PATCH] refactor: share platform file and route helpers --- .../conversationFileCapabilities.ts | 10 +-- .../proxy-core/surfaces/filesSurface.ts | 12 +--- src/server/routes/proxy/logPathMeta.ts | 18 +---- src/server/services/backupService.ts | 71 +++--------------- src/server/services/platforms/claude.ts | 5 +- src/server/services/platforms/index.ts | 72 +------------------ src/server/services/proxyInputFileResolver.ts | 8 +-- .../gemini/generate-content/index.test.ts | 14 +++- .../gemini/generate-content/index.ts | 48 +++---------- .../gemini/generate-content/outbound.ts | 50 +++---------- .../gemini/generate-content/urlResolver.ts | 38 ++++++++++ src/shared/conversationFileTypes.d.ts | 16 +++++ src/shared/conversationFileTypes.js | 52 ++++++++++++++ src/shared/conversationFileTypes.test.ts | 34 +++++++++ src/shared/platformIdentity.d.ts | 3 + src/shared/platformIdentity.js | 65 +++++++++++++++++ src/shared/platformIdentity.test.ts | 24 +++++++ src/shared/proxyLogMeta.d.ts | 9 +++ src/shared/proxyLogMeta.js | 18 +++++ src/shared/proxyLogMeta.test.ts | 27 +++++++ src/web/pages/Settings.tsx | 38 +++++----- .../helpers/conversationFileCapabilities.ts | 47 +++--------- src/web/pages/helpers/navigationFocus.ts | 8 +-- src/web/pages/helpers/proxyLogPathMeta.ts | 21 +++--- 24 files changed, 375 insertions(+), 333 deletions(-) create mode 100644 src/server/transformers/gemini/generate-content/urlResolver.ts create mode 100644 src/shared/conversationFileTypes.d.ts create mode 100644 src/shared/conversationFileTypes.js create mode 100644 src/shared/conversationFileTypes.test.ts create mode 100644 src/shared/platformIdentity.d.ts create mode 100644 src/shared/platformIdentity.js create mode 100644 src/shared/platformIdentity.test.ts create mode 100644 src/shared/proxyLogMeta.d.ts create mode 100644 src/shared/proxyLogMeta.js create mode 100644 src/shared/proxyLogMeta.test.ts diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts index b4b4882..bd46e90 100644 --- a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts +++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts @@ -1,4 +1,5 @@ import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; +import { classifyConversationFileMimeType } from '../../../shared/conversationFileTypes.js'; export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native'; export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses'; @@ -25,13 +26,6 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -function classifyMimeFamily(mimeType: string | null): 'image' | 'audio' | 'document' { - const normalized = asTrimmedString(mimeType).toLowerCase(); - if (normalized.startsWith('image/')) return 'image'; - if (normalized.startsWith('audio/')) return 'audio'; - return 'document'; -} - function appendConversationFileSummary( summary: ConversationFileInputSummary, item: Record, @@ -39,7 +33,7 @@ function appendConversationFileSummary( const normalizedFile = normalizeInputFileBlock(item); if (normalizedFile) { const mimeType = inferInputFileMimeType(normalizedFile); - const family = classifyMimeFamily(mimeType); + const family = classifyConversationFileMimeType(mimeType); if (family === 'image') { summary.hasImage = true; return; diff --git a/src/server/proxy-core/surfaces/filesSurface.ts b/src/server/proxy-core/surfaces/filesSurface.ts index fdb3b87..3194e25 100644 --- a/src/server/proxy-core/surfaces/filesSurface.ts +++ b/src/server/proxy-core/surfaces/filesSurface.ts @@ -8,6 +8,7 @@ import { softDeleteProxyFileByPublicIdForOwner, } from '../../services/proxyFileStore.js'; import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js'; +import { isSupportedConversationFileMimeType } from '../../../shared/conversationFileTypes.js'; function invalidRequest(reply: FastifyReply, message: string) { return reply.code(400).send({ error: { message, type: 'invalid_request_error' } }); @@ -58,15 +59,6 @@ function inferMimeTypeFromFilename(filename: string): string { return 'application/octet-stream'; } -function isSupportedProxyFileMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); -} - export async function filesProxyRoute(app: FastifyInstance) { ensureMultipartBufferParser(app); @@ -88,7 +80,7 @@ export async function filesProxyRoute(app: FastifyInstance) { const filename = fileEntry.name || 'upload.bin'; const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase(); - if (!isSupportedProxyFileMimeType(mimeType)) { + if (!isSupportedConversationFileMimeType(mimeType)) { return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`); } diff --git a/src/server/routes/proxy/logPathMeta.ts b/src/server/routes/proxy/logPathMeta.ts index 9b72cde..35a3175 100644 --- a/src/server/routes/proxy/logPathMeta.ts +++ b/src/server/routes/proxy/logPathMeta.ts @@ -1,3 +1,5 @@ +import { parseProxyLogMetadata } from '../../../shared/proxyLogMeta.js'; + type ComposeProxyLogMessageArgs = { clientKind?: string | null; sessionId?: string | null; @@ -16,21 +18,7 @@ export type ParsedProxyLogMessageMeta = { }; export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta { - const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i); - const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i); - const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i); - const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i); - const messageText = rawMessage.replace( - /^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i, - '', - ).trim(); - return { - clientKind: clientMatch?.[1]?.trim() || null, - sessionId: sessionMatch?.[1]?.trim() || null, - downstreamPath: downstreamMatch?.[1]?.trim() || null, - upstreamPath: upstreamMatch?.[1]?.trim() || null, - messageText, - }; + return parseProxyLogMetadata(rawMessage); } export function composeProxyLogMessage({ diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 4ea984f..93227db 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js'; import { upsertSetting } from '../db/upsertSetting.js'; import { mergeAccountExtraConfig } from './accountExtraConfig.js'; import { getOauthInfoFromAccount } from './oauth/oauthAccount.js'; +import { PLATFORM_ALIASES, detectPlatformByUrlHint } from '../../shared/platformIdentity.js'; const BACKUP_VERSION = '2.1'; @@ -244,38 +245,6 @@ const DIRECT_API_PLATFORMS = new Set([ 'antigravity', ]); -const IMPORT_PLATFORM_ALIASES: Record = { - anyrouter: 'anyrouter', - 'wong-gongyi': 'new-api', - 'vo-api': 'new-api', - 'super-api': 'new-api', - 'rix-api': 'new-api', - 'neo-api': 'new-api', - newapi: 'new-api', - 'new api': 'new-api', - 'new-api': 'new-api', - oneapi: 'one-api', - 'one api': 'one-api', - 'one-api': 'one-api', - onehub: 'one-hub', - 'one-hub': 'one-hub', - donehub: 'done-hub', - 'done-hub': 'done-hub', - veloera: 'veloera', - sub2api: 'sub2api', - openai: 'openai', - anthropic: 'claude', - claude: 'claude', - google: 'gemini', - gemini: 'gemini', - cliproxyapi: 'cliproxyapi', - cpa: 'cliproxyapi', - 'cli-proxy-api': 'cliproxyapi', - codex: 'codex', - 'gemini-cli': 'gemini-cli', - antigravity: 'antigravity', -}; - function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } @@ -645,38 +614,14 @@ function normalizeOriginUrl(raw: string): string { } } -function detectLocalPlatformByUrlHint(url: string): string | undefined { - const normalized = url.trim().toLowerCase(); - if (!normalized) return undefined; - - if (normalized.includes('api.openai.com')) return 'openai'; - if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; - if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; - if ( - normalized.includes('generativelanguage.googleapis.com') - || normalized.includes('googleapis.com/v1beta/openai') - || normalized.includes('gemini.google.com') - ) { - return 'gemini'; - } - if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; - if (normalized.includes('anyrouter')) return 'anyrouter'; - if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; - if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; - if (normalized.includes('veloera')) return 'veloera'; - if (normalized.includes('sub2api')) return 'sub2api'; - if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; - - return undefined; -} - function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string { - const normalizedPlatform = typeof rawPlatform === 'string' - ? IMPORT_PLATFORM_ALIASES[rawPlatform.trim().toLowerCase()] - : undefined; + const rawPlatformText = asString(rawPlatform).toLowerCase(); + const normalizedPlatform = rawPlatformText + ? (PLATFORM_ALIASES[rawPlatformText] ?? (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : '')) + : ''; if (normalizedPlatform) return normalizedPlatform; - const urlHint = detectLocalPlatformByUrlHint(rawUrl); + const urlHint = detectPlatformByUrlHint(rawUrl); if (urlHint) return urlHint; return normalizeLegacyPlatform(asString(rawPlatform)); @@ -688,9 +633,9 @@ function resolveImportedProfilePlatform(apiType: unknown, baseUrl: string): stri if (normalizedType === 'anthropic') return 'claude'; if (normalizedType === 'google') return 'gemini'; if (normalizedType === 'openai-compatible') { - return detectLocalPlatformByUrlHint(baseUrl) || 'openai'; + return detectPlatformByUrlHint(baseUrl) || 'openai'; } - return detectLocalPlatformByUrlHint(baseUrl) || 'openai'; + return detectPlatformByUrlHint(baseUrl) || 'openai'; } function pushDefaultImportedToken( diff --git a/src/server/services/platforms/claude.ts b/src/server/services/platforms/claude.ts index 28a7e79..d56a528 100644 --- a/src/server/services/platforms/claude.ts +++ b/src/server/services/platforms/claude.ts @@ -1,6 +1,5 @@ import { StandardApiProviderAdapterBase } from './standardApiProvider.js'; - -const DEFAULT_ANTHROPIC_VERSION = '2023-06-01'; +import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from '../oauth/claudeProvider.js'; export class ClaudeAdapter extends StandardApiProviderAdapterBase { readonly platformName = 'claude'; @@ -15,7 +14,7 @@ export class ClaudeAdapter extends StandardApiProviderAdapterBase { baseUrl, headers: { 'x-api-key': apiToken, - 'anthropic-version': DEFAULT_ANTHROPIC_VERSION, + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, }, }); } diff --git a/src/server/services/platforms/index.ts b/src/server/services/platforms/index.ts index 1fc4333..aceb911 100644 --- a/src/server/services/platforms/index.ts +++ b/src/server/services/platforms/index.ts @@ -14,6 +14,7 @@ import { GeminiCliAdapter } from './geminiCli.js'; import { AntigravityAdapter } from './antigravity.js'; import { CliProxyApiAdapter } from './cliproxyapi.js'; import { detectPlatformByTitle } from './titleHint.js'; +import { detectPlatformByUrlHint, normalizePlatformAlias } from '../../../shared/platformIdentity.js'; const adapters: PlatformAdapter[] = [ // Specific forks before generic adapters for better auto-detection. @@ -33,47 +34,8 @@ const adapters: PlatformAdapter[] = [ new OneApiAdapter(), ]; -const platformAliases: Record = { - // NewAPI family aliases - anyrouter: 'anyrouter', - 'wong-gongyi': 'new-api', - 'vo-api': 'new-api', - 'super-api': 'new-api', - 'rix-api': 'new-api', - 'neo-api': 'new-api', - newapi: 'new-api', - 'new api': 'new-api', - // OneAPI family aliases - oneapi: 'one-api', - 'one api': 'one-api', - // Keep canonical forms explicit for clarity - 'new-api': 'new-api', - 'one-api': 'one-api', - veloera: 'veloera', - 'one-hub': 'one-hub', - 'done-hub': 'done-hub', - sub2api: 'sub2api', - // Official upstream APIs - openai: 'openai', - codex: 'codex', - 'chatgpt-codex': 'codex', - 'chatgpt codex': 'codex', - anthropic: 'claude', - claude: 'claude', - gemini: 'gemini', - 'gemini-cli': 'gemini-cli', - antigravity: 'antigravity', - 'anti-gravity': 'antigravity', - google: 'gemini', - // CLIProxyAPI aliases - cliproxyapi: 'cliproxyapi', - cpa: 'cliproxyapi', - 'cli-proxy-api': 'cliproxyapi', -}; - function normalizePlatform(platform: string): string { - const raw = (platform || '').trim().toLowerCase(); - return platformAliases[raw] ?? raw; + return normalizePlatformAlias(platform); } export function getAdapter(platform: string): PlatformAdapter | undefined { @@ -89,36 +51,6 @@ const titleFirstPlatforms = new Set([ 'sub2api', ]); -function detectPlatformByUrlHint(url: string): string | undefined { - const normalized = (url || '').trim().toLowerCase(); - if (!normalized) return undefined; - - // Official upstream endpoints. - if (normalized.includes('api.openai.com')) return 'openai'; - if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; - if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; - if ( - normalized.includes('generativelanguage.googleapis.com') - || normalized.includes('googleapis.com/v1beta/openai') - || normalized.includes('gemini.google.com') - ) { - return 'gemini'; - } - if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; - - // NewAPI-family forks and common aliases. - if (normalized.includes('anyrouter')) return 'anyrouter'; - if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; - if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; - if (normalized.includes('veloera')) return 'veloera'; - if (normalized.includes('sub2api')) return 'sub2api'; - - // CLIProxyAPI default local endpoints. - if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; - - return undefined; -} - export async function detectPlatform(url: string): Promise { const urlHint = detectPlatformByUrlHint(url); if (urlHint) { diff --git a/src/server/services/proxyInputFileResolver.ts b/src/server/services/proxyInputFileResolver.ts index e3d9290..d42d08a 100644 --- a/src/server/services/proxyInputFileResolver.ts +++ b/src/server/services/proxyInputFileResolver.ts @@ -2,6 +2,7 @@ import type { ProxyResourceOwner } from '../middleware/auth.js'; import { getProxyFileByPublicIdForOwner } from './proxyFileStore.js'; import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js'; +import { isSupportedConversationFileMimeType } from '../../shared/conversationFileTypes.js'; const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-'; @@ -44,12 +45,7 @@ function inferMimeTypeFromFilename(filename: string): string { } function isSupportedMimeType(mimeType: string): boolean { - return mimeType === 'application/pdf' - || mimeType === 'text/plain' - || mimeType === 'text/markdown' - || mimeType === 'application/json' - || mimeType.startsWith('image/') - || mimeType.startsWith('audio/'); + return isSupportedConversationFileMimeType(mimeType); } function audioFormatFromMimeType(mimeType: string, filename: string): string { diff --git a/src/server/transformers/gemini/generate-content/index.test.ts b/src/server/transformers/gemini/generate-content/index.test.ts index ac6dad4..c94d77e 100644 --- a/src/server/transformers/gemini/generate-content/index.test.ts +++ b/src/server/transformers/gemini/generate-content/index.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest'; import { geminiGenerateContentTransformer, + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, } from './index.js'; import { extractGeminiUsage } from './usage.js'; -import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js'; +import { serializeGeminiAggregateResponse, extractResponseMetadata, geminiGenerateContentOutbound } from './outbound.js'; import { resolveGeminiThinkingConfigFromRequest } from './convert.js'; import { parseGeminiStreamPayload, @@ -15,6 +18,15 @@ import { } from './stream.js'; describe('geminiGenerateContentTransformer.inbound', () => { + it('reuses the same gemini url resolver helpers across transformer layers', () => { + expect(geminiGenerateContentTransformer.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl); + expect(geminiGenerateContentTransformer.resolveModelsUrl).toBe(resolveGeminiModelsUrl); + expect(geminiGenerateContentTransformer.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl); + expect(geminiGenerateContentOutbound.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl); + expect(geminiGenerateContentOutbound.resolveModelsUrl).toBe(resolveGeminiModelsUrl); + expect(geminiGenerateContentOutbound.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl); + }); + it('parses native Gemini requests into canonical envelopes', () => { const result = geminiGenerateContentTransformer.parseRequest({ model: 'gemini-2.5-pro', diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts index 4d71fbc..9661f88 100644 --- a/src/server/transformers/gemini/generate-content/index.ts +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -1,13 +1,16 @@ import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js'; import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js'; import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js'; -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function baseIncludesVersion(baseUrl: string): boolean { - return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); -} +import { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; +export { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; @@ -17,37 +20,6 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } -export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (baseIncludesVersion(normalized)) return normalized; - return `${normalized}/${apiVersion}`; -} - -export function resolveGeminiModelsUrl( - baseUrl: string, - apiVersion: string, - apiKey: string, -): string { - const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); - const separator = base.includes('?') ? '&' : '?'; - return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`; -} - -export function resolveGeminiGenerateContentUrl( - baseUrl: string, - apiVersion: string, - modelActionPath: string, - apiKey: string, - search: string, -): string { - const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); - const normalizedAction = modelActionPath.replace(/^\/+/, ''); - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); - params.set('key', apiKey); - const query = params.toString(); - return `${base}/${normalizedAction}${query ? `?${query}` : ''}`; -} - export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string { return asTrimmedString(params?.geminiApiVersion) || 'v1beta'; } diff --git a/src/server/transformers/gemini/generate-content/outbound.ts b/src/server/transformers/gemini/generate-content/outbound.ts index 2a9da9f..f692ab8 100644 --- a/src/server/transformers/gemini/generate-content/outbound.ts +++ b/src/server/transformers/gemini/generate-content/outbound.ts @@ -7,45 +7,11 @@ import { toTransformerMetadataRecord, type TransformerMetadata, } from '../../shared/normalized.js'; - -function normalizeBaseUrl(baseUrl: string): string { - return (baseUrl || '').replace(/\/+$/, ''); -} - -function baseIncludesVersion(baseUrl: string): boolean { - return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); -} - -function resolveBaseUrl(baseUrl: string, apiVersion: string): string { - const normalized = normalizeBaseUrl(baseUrl); - if (baseIncludesVersion(normalized)) return normalized; - return `${normalized}/${apiVersion}`; -} - -function resolveModelsUrl( - baseUrl: string, - apiVersion: string, - apiKey: string, -): string { - const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion); - const separator = resolvedBaseUrl.includes('?') ? '&' : '?'; - return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`; -} - -function resolveActionUrl( - baseUrl: string, - apiVersion: string, - modelActionPath: string, - apiKey: string, - search: string, -): string { - const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion); - const normalizedAction = modelActionPath.replace(/^\/+/, ''); - const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); - params.set('key', apiKey); - const query = params.toString(); - return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`; -} +import { + resolveGeminiGenerateContentUrl, + resolveGeminiModelsUrl, + resolveGeminiNativeBaseUrl, +} from './urlResolver.js'; type GeminiRecord = Record; @@ -304,9 +270,9 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno } export const geminiGenerateContentOutbound = { - resolveBaseUrl, - resolveModelsUrl, - resolveActionUrl, + resolveBaseUrl: resolveGeminiNativeBaseUrl, + resolveModelsUrl: resolveGeminiModelsUrl, + resolveActionUrl: resolveGeminiGenerateContentUrl, extractTransformerMetadata, extractResponseMetadata, serializeAggregateResponse: serializeGeminiAggregateResponse, diff --git a/src/server/transformers/gemini/generate-content/urlResolver.ts b/src/server/transformers/gemini/generate-content/urlResolver.ts new file mode 100644 index 0000000..f48c2f7 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/urlResolver.ts @@ -0,0 +1,38 @@ +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function baseIncludesVersion(baseUrl: string): boolean { + return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl); +} + +export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string { + const normalized = normalizeBaseUrl(baseUrl); + if (baseIncludesVersion(normalized)) return normalized; + return `${normalized}/${apiVersion}`; +} + +export function resolveGeminiModelsUrl( + baseUrl: string, + apiVersion: string, + apiKey: string, +): string { + const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); + const separator = base.includes('?') ? '&' : '?'; + return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`; +} + +export function resolveGeminiGenerateContentUrl( + baseUrl: string, + apiVersion: string, + modelActionPath: string, + apiKey: string, + search: string, +): string { + const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + params.set('key', apiKey); + const query = params.toString(); + return `${base}/${normalizedAction}${query ? `?${query}` : ''}`; +} diff --git a/src/shared/conversationFileTypes.d.ts b/src/shared/conversationFileTypes.d.ts new file mode 100644 index 0000000..58e4941 --- /dev/null +++ b/src/shared/conversationFileTypes.d.ts @@ -0,0 +1,16 @@ +export type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown'; + +export declare const CONVERSATION_DOCUMENT_ACCEPT_PARTS: string[]; +export declare function classifyConversationFileMimeType( + mimeType: string | null | undefined, +): Exclude; +export declare function detectConversationFileKind(file: { + filename?: string | null; + mimeType?: string | null; +}): ConversationFileKind; +export declare function isSupportedConversationFileMimeType(mimeType: string): boolean; +export declare function buildConversationAcceptList(input: { + document: boolean; + image: boolean; + audio: boolean; +}): string; diff --git a/src/shared/conversationFileTypes.js b/src/shared/conversationFileTypes.js new file mode 100644 index 0000000..8f44065 --- /dev/null +++ b/src/shared/conversationFileTypes.js @@ -0,0 +1,52 @@ +export const CONVERSATION_DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json']; +const DOCUMENT_MIME_TYPES = new Set([ + 'application/json', + 'application/pdf', + 'text/markdown', + 'text/plain', +]); +const DOCUMENT_EXTENSIONS = ['.json', '.md', '.markdown', '.pdf', '.txt']; +const IMAGE_EXTENSIONS = ['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']; +const AUDIO_EXTENSIONS = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav', '.weba']; + +function normalizeValue(value) { + return (value || '').trim().toLowerCase(); +} + +export function classifyConversationFileMimeType(mimeType) { + const normalized = normalizeValue(mimeType); + if (normalized.startsWith('image/')) return 'image'; + if (normalized.startsWith('audio/')) return 'audio'; + return 'document'; +} + +export function detectConversationFileKind(file) { + const mimeType = normalizeValue(file?.mimeType); + if (mimeType) { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('audio/')) return 'audio'; + if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document'; + } + + const filename = normalizeValue(file?.filename); + if (!filename) return 'unknown'; + if (DOCUMENT_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'document'; + if (IMAGE_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'image'; + if (AUDIO_EXTENSIONS.some((extension) => filename.endsWith(extension))) return 'audio'; + return 'unknown'; +} + +export function isSupportedConversationFileMimeType(mimeType) { + const normalized = normalizeValue(mimeType); + return DOCUMENT_MIME_TYPES.has(normalized) + || normalized.startsWith('image/') + || normalized.startsWith('audio/'); +} + +export function buildConversationAcceptList(input) { + const parts = []; + if (input.document) parts.push(...CONVERSATION_DOCUMENT_ACCEPT_PARTS); + if (input.image) parts.push('image/*'); + if (input.audio) parts.push('audio/*'); + return parts.join(','); +} diff --git a/src/shared/conversationFileTypes.test.ts b/src/shared/conversationFileTypes.test.ts new file mode 100644 index 0000000..2bfc229 --- /dev/null +++ b/src/shared/conversationFileTypes.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildConversationAcceptList, + classifyConversationFileMimeType, + detectConversationFileKind, + isSupportedConversationFileMimeType, +} from './conversationFileTypes.js'; + +describe('conversationFileTypes', () => { + it('classifies conversation file kinds from mime type and filename', () => { + expect(classifyConversationFileMimeType('image/png')).toBe('image'); + expect(classifyConversationFileMimeType('audio/mpeg')).toBe('audio'); + expect(classifyConversationFileMimeType('application/pdf')).toBe('document'); + + expect(detectConversationFileKind({ filename: 'paper.pdf', mimeType: null })).toBe('document'); + expect(detectConversationFileKind({ filename: 'photo.webp', mimeType: null })).toBe('image'); + expect(detectConversationFileKind({ filename: 'voice.mp3', mimeType: null })).toBe('audio'); + expect(detectConversationFileKind({ filename: 'unknown.bin', mimeType: null })).toBe('unknown'); + }); + + it('keeps supported mime and accept list rules in one place', () => { + expect(isSupportedConversationFileMimeType('application/pdf')).toBe(true); + expect(isSupportedConversationFileMimeType('image/jpeg')).toBe(true); + expect(isSupportedConversationFileMimeType('audio/wav')).toBe(true); + expect(isSupportedConversationFileMimeType('application/octet-stream')).toBe(false); + + expect(buildConversationAcceptList({ + document: true, + image: true, + audio: false, + })).toBe('.pdf,.txt,.md,.markdown,.json,image/*'); + }); +}); diff --git a/src/shared/platformIdentity.d.ts b/src/shared/platformIdentity.d.ts new file mode 100644 index 0000000..d6b308c --- /dev/null +++ b/src/shared/platformIdentity.d.ts @@ -0,0 +1,3 @@ +export declare const PLATFORM_ALIASES: Record; +export declare function normalizePlatformAlias(platform: unknown): string; +export declare function detectPlatformByUrlHint(url: string): string | undefined; diff --git a/src/shared/platformIdentity.js b/src/shared/platformIdentity.js new file mode 100644 index 0000000..f4fba11 --- /dev/null +++ b/src/shared/platformIdentity.js @@ -0,0 +1,65 @@ +export const PLATFORM_ALIASES = { + anyrouter: 'anyrouter', + 'wong-gongyi': 'new-api', + 'vo-api': 'new-api', + 'super-api': 'new-api', + 'rix-api': 'new-api', + 'neo-api': 'new-api', + newapi: 'new-api', + 'new api': 'new-api', + 'new-api': 'new-api', + oneapi: 'one-api', + 'one api': 'one-api', + 'one-api': 'one-api', + onehub: 'one-hub', + 'one-hub': 'one-hub', + donehub: 'done-hub', + 'done-hub': 'done-hub', + veloera: 'veloera', + sub2api: 'sub2api', + openai: 'openai', + codex: 'codex', + 'chatgpt-codex': 'codex', + 'chatgpt codex': 'codex', + anthropic: 'claude', + claude: 'claude', + gemini: 'gemini', + 'gemini-cli': 'gemini-cli', + antigravity: 'antigravity', + 'anti-gravity': 'antigravity', + google: 'gemini', + cliproxyapi: 'cliproxyapi', + cpa: 'cliproxyapi', + 'cli-proxy-api': 'cliproxyapi', +}; + +export function normalizePlatformAlias(platform) { + const raw = typeof platform === 'string' ? platform.trim().toLowerCase() : ''; + if (!raw) return ''; + return PLATFORM_ALIASES[raw] ?? raw; +} + +export function detectPlatformByUrlHint(url) { + const normalized = (url || '').trim().toLowerCase(); + if (!normalized) return undefined; + + if (normalized.includes('api.openai.com')) return 'openai'; + if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; + if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; + if ( + normalized.includes('generativelanguage.googleapis.com') + || normalized.includes('googleapis.com/v1beta/openai') + || normalized.includes('gemini.google.com') + ) { + return 'gemini'; + } + if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; + if (normalized.includes('anyrouter')) return 'anyrouter'; + if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub'; + if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub'; + if (normalized.includes('veloera')) return 'veloera'; + if (normalized.includes('sub2api')) return 'sub2api'; + if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi'; + + return undefined; +} diff --git a/src/shared/platformIdentity.test.ts b/src/shared/platformIdentity.test.ts new file mode 100644 index 0000000..2cdc904 --- /dev/null +++ b/src/shared/platformIdentity.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { + detectPlatformByUrlHint, + normalizePlatformAlias, +} from './platformIdentity.js'; + +describe('platformIdentity', () => { + it('normalizes shared platform aliases', () => { + expect(normalizePlatformAlias('chatgpt-codex')).toBe('codex'); + expect(normalizePlatformAlias('anti-gravity')).toBe('antigravity'); + expect(normalizePlatformAlias('one api')).toBe('one-api'); + expect(normalizePlatformAlias('')).toBe(''); + }); + + it('detects platform by well-known url hints', () => { + expect(detectPlatformByUrlHint('https://api.openai.com/v1/models')).toBe('openai'); + expect(detectPlatformByUrlHint('https://chatgpt.com/backend-api/codex')).toBe('codex'); + expect(detectPlatformByUrlHint('https://api.anthropic.com/v1/messages')).toBe('claude'); + expect(detectPlatformByUrlHint('https://generativelanguage.googleapis.com/v1beta')).toBe('gemini'); + expect(detectPlatformByUrlHint('https://cloudcode-pa.googleapis.com')).toBe('gemini-cli'); + expect(detectPlatformByUrlHint('http://127.0.0.1:8317/v1/models')).toBe('cliproxyapi'); + }); +}); diff --git a/src/shared/proxyLogMeta.d.ts b/src/shared/proxyLogMeta.d.ts new file mode 100644 index 0000000..0e46208 --- /dev/null +++ b/src/shared/proxyLogMeta.d.ts @@ -0,0 +1,9 @@ +export type ParsedProxyLogMetadata = { + clientKind: string | null; + sessionId: string | null; + downstreamPath: string | null; + upstreamPath: string | null; + messageText: string; +}; + +export declare function parseProxyLogMetadata(rawMessage: string): ParsedProxyLogMetadata; diff --git a/src/shared/proxyLogMeta.js b/src/shared/proxyLogMeta.js new file mode 100644 index 0000000..902b745 --- /dev/null +++ b/src/shared/proxyLogMeta.js @@ -0,0 +1,18 @@ +export function parseProxyLogMetadata(rawMessage) { + const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i); + const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i); + const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i); + const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i); + const messageText = rawMessage.replace( + /^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i, + '', + ).trim(); + + return { + clientKind: clientMatch?.[1]?.trim() || null, + sessionId: sessionMatch?.[1]?.trim() || null, + downstreamPath: downstreamMatch?.[1]?.trim() || null, + upstreamPath: upstreamMatch?.[1]?.trim() || null, + messageText, + }; +} diff --git a/src/shared/proxyLogMeta.test.ts b/src/shared/proxyLogMeta.test.ts new file mode 100644 index 0000000..1e77042 --- /dev/null +++ b/src/shared/proxyLogMeta.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { parseProxyLogMetadata } from './proxyLogMeta.js'; + +describe('proxyLogMeta', () => { + it('parses all supported proxy log prefixes', () => { + expect(parseProxyLogMetadata( + '[client:codex] [session:turn-1] [downstream:/v1/responses] [upstream:/responses] boom', + )).toEqual({ + clientKind: 'codex', + sessionId: 'turn-1', + downstreamPath: '/v1/responses', + upstreamPath: '/responses', + messageText: 'boom', + }); + }); + + it('keeps plain message text when no metadata prefixes exist', () => { + expect(parseProxyLogMetadata('network timeout')).toEqual({ + clientKind: null, + sessionId: null, + downstreamPath: null, + upstreamPath: null, + messageText: 'network timeout', + }); + }); +}); diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index c48a670..09fcbb2 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -19,9 +19,9 @@ import { clearAppInstallationState } from '../appLocalState.js'; import { tr } from '../i18n.js'; import { isExactModelPattern, - parseBrandIconValue, + resolveRouteBrand, + resolveRouteIcon, resolveRouteTitle, - ROUTE_ICON_NONE_VALUE, } from './token-routes/utils.js'; import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js'; @@ -178,12 +178,6 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null return null; } -function resolveRouteBrandSource(route: RouteSelectorItem): string { - const title = resolveRouteTitle(route); - if (getBrand(title)) return title; - return route.modelPattern; -} - export default function Settings() { const navigate = useNavigate(); const isMobile = useIsMobile(); @@ -1907,9 +1901,9 @@ export default function Settings() {
没有匹配的群组
) : filteredGroupRouteOptions.map((route) => { const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id); - const explicitBrandIcon = parseBrandIconValue(route.displayIcon || ''); - const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE; - const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim(); + const routeTitle = resolveRouteTitle(route); + const routeIcon = resolveRouteIcon(route); + const routeBrand = resolveRouteBrand(route); return (