refactor: share platform file and route helpers
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||||
|
import { classifyConversationFileMimeType } from '../../../shared/conversationFileTypes.js';
|
||||||
|
|
||||||
export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native';
|
export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native';
|
||||||
export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses';
|
export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses';
|
||||||
@@ -25,13 +26,6 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyMimeFamily(mimeType: string | null): 'image' | 'audio' | 'document' {
|
|
||||||
const normalized = asTrimmedString(mimeType).toLowerCase();
|
|
||||||
if (normalized.startsWith('image/')) return 'image';
|
|
||||||
if (normalized.startsWith('audio/')) return 'audio';
|
|
||||||
return 'document';
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendConversationFileSummary(
|
function appendConversationFileSummary(
|
||||||
summary: ConversationFileInputSummary,
|
summary: ConversationFileInputSummary,
|
||||||
item: Record<string, unknown>,
|
item: Record<string, unknown>,
|
||||||
@@ -39,7 +33,7 @@ function appendConversationFileSummary(
|
|||||||
const normalizedFile = normalizeInputFileBlock(item);
|
const normalizedFile = normalizeInputFileBlock(item);
|
||||||
if (normalizedFile) {
|
if (normalizedFile) {
|
||||||
const mimeType = inferInputFileMimeType(normalizedFile);
|
const mimeType = inferInputFileMimeType(normalizedFile);
|
||||||
const family = classifyMimeFamily(mimeType);
|
const family = classifyConversationFileMimeType(mimeType);
|
||||||
if (family === 'image') {
|
if (family === 'image') {
|
||||||
summary.hasImage = true;
|
summary.hasImage = true;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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';
|
||||||
|
|
||||||
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' } });
|
||||||
@@ -58,15 +59,6 @@ function inferMimeTypeFromFilename(filename: string): string {
|
|||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedProxyFileMimeType(mimeType: string): boolean {
|
|
||||||
return mimeType === 'application/pdf'
|
|
||||||
|| mimeType === 'text/plain'
|
|
||||||
|| mimeType === 'text/markdown'
|
|
||||||
|| mimeType === 'application/json'
|
|
||||||
|| mimeType.startsWith('image/')
|
|
||||||
|| mimeType.startsWith('audio/');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function filesProxyRoute(app: FastifyInstance) {
|
export async function filesProxyRoute(app: FastifyInstance) {
|
||||||
ensureMultipartBufferParser(app);
|
ensureMultipartBufferParser(app);
|
||||||
|
|
||||||
@@ -88,7 +80,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 = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase();
|
||||||
if (!isSupportedProxyFileMimeType(mimeType)) {
|
if (!isSupportedConversationFileMimeType(mimeType)) {
|
||||||
return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`);
|
return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parseProxyLogMetadata } from '../../../shared/proxyLogMeta.js';
|
||||||
|
|
||||||
type ComposeProxyLogMessageArgs = {
|
type ComposeProxyLogMessageArgs = {
|
||||||
clientKind?: string | null;
|
clientKind?: string | null;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
@@ -16,21 +18,7 @@ export type ParsedProxyLogMessageMeta = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
|
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
|
||||||
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
return parseProxyLogMetadata(rawMessage);
|
||||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
|
||||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
|
||||||
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
|
|
||||||
const messageText = rawMessage.replace(
|
|
||||||
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
|
|
||||||
'',
|
|
||||||
).trim();
|
|
||||||
return {
|
|
||||||
clientKind: clientMatch?.[1]?.trim() || null,
|
|
||||||
sessionId: sessionMatch?.[1]?.trim() || null,
|
|
||||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
|
||||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
|
||||||
messageText,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function composeProxyLogMessage({
|
export function composeProxyLogMessage({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { db, schema } from '../db/index.js';
|
|||||||
import { upsertSetting } from '../db/upsertSetting.js';
|
import { upsertSetting } from '../db/upsertSetting.js';
|
||||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||||
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||||
|
import { PLATFORM_ALIASES, detectPlatformByUrlHint } from '../../shared/platformIdentity.js';
|
||||||
|
|
||||||
const BACKUP_VERSION = '2.1';
|
const BACKUP_VERSION = '2.1';
|
||||||
|
|
||||||
@@ -244,38 +245,6 @@ const DIRECT_API_PLATFORMS = new Set([
|
|||||||
'antigravity',
|
'antigravity',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const IMPORT_PLATFORM_ALIASES: Record<string, string> = {
|
|
||||||
anyrouter: 'anyrouter',
|
|
||||||
'wong-gongyi': 'new-api',
|
|
||||||
'vo-api': 'new-api',
|
|
||||||
'super-api': 'new-api',
|
|
||||||
'rix-api': 'new-api',
|
|
||||||
'neo-api': 'new-api',
|
|
||||||
newapi: 'new-api',
|
|
||||||
'new api': 'new-api',
|
|
||||||
'new-api': 'new-api',
|
|
||||||
oneapi: 'one-api',
|
|
||||||
'one api': 'one-api',
|
|
||||||
'one-api': 'one-api',
|
|
||||||
onehub: 'one-hub',
|
|
||||||
'one-hub': 'one-hub',
|
|
||||||
donehub: 'done-hub',
|
|
||||||
'done-hub': 'done-hub',
|
|
||||||
veloera: 'veloera',
|
|
||||||
sub2api: 'sub2api',
|
|
||||||
openai: 'openai',
|
|
||||||
anthropic: 'claude',
|
|
||||||
claude: 'claude',
|
|
||||||
google: 'gemini',
|
|
||||||
gemini: 'gemini',
|
|
||||||
cliproxyapi: 'cliproxyapi',
|
|
||||||
cpa: 'cliproxyapi',
|
|
||||||
'cli-proxy-api': 'cliproxyapi',
|
|
||||||
codex: 'codex',
|
|
||||||
'gemini-cli': 'gemini-cli',
|
|
||||||
antigravity: 'antigravity',
|
|
||||||
};
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
@@ -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 {
|
function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string {
|
||||||
const normalizedPlatform = typeof rawPlatform === 'string'
|
const rawPlatformText = asString(rawPlatform).toLowerCase();
|
||||||
? IMPORT_PLATFORM_ALIASES[rawPlatform.trim().toLowerCase()]
|
const normalizedPlatform = rawPlatformText
|
||||||
: undefined;
|
? (PLATFORM_ALIASES[rawPlatformText] ?? (DIRECT_API_PLATFORMS.has(rawPlatformText) ? rawPlatformText : ''))
|
||||||
|
: '';
|
||||||
if (normalizedPlatform) return normalizedPlatform;
|
if (normalizedPlatform) return normalizedPlatform;
|
||||||
|
|
||||||
const urlHint = detectLocalPlatformByUrlHint(rawUrl);
|
const urlHint = detectPlatformByUrlHint(rawUrl);
|
||||||
if (urlHint) return urlHint;
|
if (urlHint) return urlHint;
|
||||||
|
|
||||||
return normalizeLegacyPlatform(asString(rawPlatform));
|
return normalizeLegacyPlatform(asString(rawPlatform));
|
||||||
@@ -688,9 +633,9 @@ function resolveImportedProfilePlatform(apiType: unknown, baseUrl: string): stri
|
|||||||
if (normalizedType === 'anthropic') return 'claude';
|
if (normalizedType === 'anthropic') return 'claude';
|
||||||
if (normalizedType === 'google') return 'gemini';
|
if (normalizedType === 'google') return 'gemini';
|
||||||
if (normalizedType === 'openai-compatible') {
|
if (normalizedType === 'openai-compatible') {
|
||||||
return detectLocalPlatformByUrlHint(baseUrl) || 'openai';
|
return detectPlatformByUrlHint(baseUrl) || 'openai';
|
||||||
}
|
}
|
||||||
return detectLocalPlatformByUrlHint(baseUrl) || 'openai';
|
return detectPlatformByUrlHint(baseUrl) || 'openai';
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushDefaultImportedToken(
|
function pushDefaultImportedToken(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { StandardApiProviderAdapterBase } from './standardApiProvider.js';
|
import { StandardApiProviderAdapterBase } from './standardApiProvider.js';
|
||||||
|
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from '../oauth/claudeProvider.js';
|
||||||
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
|
|
||||||
|
|
||||||
export class ClaudeAdapter extends StandardApiProviderAdapterBase {
|
export class ClaudeAdapter extends StandardApiProviderAdapterBase {
|
||||||
readonly platformName = 'claude';
|
readonly platformName = 'claude';
|
||||||
@@ -15,7 +14,7 @@ export class ClaudeAdapter extends StandardApiProviderAdapterBase {
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
headers: {
|
headers: {
|
||||||
'x-api-key': apiToken,
|
'x-api-key': apiToken,
|
||||||
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
|
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GeminiCliAdapter } from './geminiCli.js';
|
|||||||
import { AntigravityAdapter } from './antigravity.js';
|
import { AntigravityAdapter } from './antigravity.js';
|
||||||
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
import { CliProxyApiAdapter } from './cliproxyapi.js';
|
||||||
import { detectPlatformByTitle } from './titleHint.js';
|
import { detectPlatformByTitle } from './titleHint.js';
|
||||||
|
import { detectPlatformByUrlHint, normalizePlatformAlias } from '../../../shared/platformIdentity.js';
|
||||||
|
|
||||||
const adapters: PlatformAdapter[] = [
|
const adapters: PlatformAdapter[] = [
|
||||||
// Specific forks before generic adapters for better auto-detection.
|
// Specific forks before generic adapters for better auto-detection.
|
||||||
@@ -33,47 +34,8 @@ const adapters: PlatformAdapter[] = [
|
|||||||
new OneApiAdapter(),
|
new OneApiAdapter(),
|
||||||
];
|
];
|
||||||
|
|
||||||
const platformAliases: Record<string, string> = {
|
|
||||||
// NewAPI family aliases
|
|
||||||
anyrouter: 'anyrouter',
|
|
||||||
'wong-gongyi': 'new-api',
|
|
||||||
'vo-api': 'new-api',
|
|
||||||
'super-api': 'new-api',
|
|
||||||
'rix-api': 'new-api',
|
|
||||||
'neo-api': 'new-api',
|
|
||||||
newapi: 'new-api',
|
|
||||||
'new api': 'new-api',
|
|
||||||
// OneAPI family aliases
|
|
||||||
oneapi: 'one-api',
|
|
||||||
'one api': 'one-api',
|
|
||||||
// Keep canonical forms explicit for clarity
|
|
||||||
'new-api': 'new-api',
|
|
||||||
'one-api': 'one-api',
|
|
||||||
veloera: 'veloera',
|
|
||||||
'one-hub': 'one-hub',
|
|
||||||
'done-hub': 'done-hub',
|
|
||||||
sub2api: 'sub2api',
|
|
||||||
// Official upstream APIs
|
|
||||||
openai: 'openai',
|
|
||||||
codex: 'codex',
|
|
||||||
'chatgpt-codex': 'codex',
|
|
||||||
'chatgpt codex': 'codex',
|
|
||||||
anthropic: 'claude',
|
|
||||||
claude: 'claude',
|
|
||||||
gemini: 'gemini',
|
|
||||||
'gemini-cli': 'gemini-cli',
|
|
||||||
antigravity: 'antigravity',
|
|
||||||
'anti-gravity': 'antigravity',
|
|
||||||
google: 'gemini',
|
|
||||||
// CLIProxyAPI aliases
|
|
||||||
cliproxyapi: 'cliproxyapi',
|
|
||||||
cpa: 'cliproxyapi',
|
|
||||||
'cli-proxy-api': 'cliproxyapi',
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizePlatform(platform: string): string {
|
function normalizePlatform(platform: string): string {
|
||||||
const raw = (platform || '').trim().toLowerCase();
|
return normalizePlatformAlias(platform);
|
||||||
return platformAliases[raw] ?? raw;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdapter(platform: string): PlatformAdapter | undefined {
|
export function getAdapter(platform: string): PlatformAdapter | undefined {
|
||||||
@@ -89,36 +51,6 @@ const titleFirstPlatforms = new Set<string>([
|
|||||||
'sub2api',
|
'sub2api',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function detectPlatformByUrlHint(url: string): string | undefined {
|
|
||||||
const normalized = (url || '').trim().toLowerCase();
|
|
||||||
if (!normalized) return undefined;
|
|
||||||
|
|
||||||
// Official upstream endpoints.
|
|
||||||
if (normalized.includes('api.openai.com')) return 'openai';
|
|
||||||
if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex';
|
|
||||||
if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude';
|
|
||||||
if (
|
|
||||||
normalized.includes('generativelanguage.googleapis.com')
|
|
||||||
|| normalized.includes('googleapis.com/v1beta/openai')
|
|
||||||
|| normalized.includes('gemini.google.com')
|
|
||||||
) {
|
|
||||||
return 'gemini';
|
|
||||||
}
|
|
||||||
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
|
|
||||||
|
|
||||||
// NewAPI-family forks and common aliases.
|
|
||||||
if (normalized.includes('anyrouter')) return 'anyrouter';
|
|
||||||
if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub';
|
|
||||||
if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub';
|
|
||||||
if (normalized.includes('veloera')) return 'veloera';
|
|
||||||
if (normalized.includes('sub2api')) return 'sub2api';
|
|
||||||
|
|
||||||
// CLIProxyAPI default local endpoints.
|
|
||||||
if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi';
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function detectPlatform(url: string): Promise<PlatformAdapter | undefined> {
|
export async function detectPlatform(url: string): Promise<PlatformAdapter | undefined> {
|
||||||
const urlHint = detectPlatformByUrlHint(url);
|
const urlHint = detectPlatformByUrlHint(url);
|
||||||
if (urlHint) {
|
if (urlHint) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ProxyResourceOwner } from '../middleware/auth.js';
|
|||||||
import { getProxyFileByPublicIdForOwner } from './proxyFileStore.js';
|
import { getProxyFileByPublicIdForOwner } 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';
|
||||||
|
|
||||||
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
|
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
|
||||||
|
|
||||||
@@ -44,12 +45,7 @@ function inferMimeTypeFromFilename(filename: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedMimeType(mimeType: string): boolean {
|
function isSupportedMimeType(mimeType: string): boolean {
|
||||||
return mimeType === 'application/pdf'
|
return isSupportedConversationFileMimeType(mimeType);
|
||||||
|| mimeType === 'text/plain'
|
|
||||||
|| mimeType === 'text/markdown'
|
|
||||||
|| mimeType === 'application/json'
|
|
||||||
|| mimeType.startsWith('image/')
|
|
||||||
|| mimeType.startsWith('audio/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function audioFormatFromMimeType(mimeType: string, filename: string): string {
|
function audioFormatFromMimeType(mimeType: string, filename: string): string {
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
geminiGenerateContentTransformer,
|
geminiGenerateContentTransformer,
|
||||||
|
resolveGeminiGenerateContentUrl,
|
||||||
|
resolveGeminiModelsUrl,
|
||||||
|
resolveGeminiNativeBaseUrl,
|
||||||
reasoningEffortToGeminiThinkingConfig,
|
reasoningEffortToGeminiThinkingConfig,
|
||||||
geminiThinkingConfigToReasoning,
|
geminiThinkingConfigToReasoning,
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
import { extractGeminiUsage } from './usage.js';
|
import { extractGeminiUsage } from './usage.js';
|
||||||
import { serializeGeminiAggregateResponse, extractResponseMetadata } from './outbound.js';
|
import { serializeGeminiAggregateResponse, extractResponseMetadata, geminiGenerateContentOutbound } from './outbound.js';
|
||||||
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
import { resolveGeminiThinkingConfigFromRequest } from './convert.js';
|
||||||
import {
|
import {
|
||||||
parseGeminiStreamPayload,
|
parseGeminiStreamPayload,
|
||||||
@@ -15,6 +18,15 @@ import {
|
|||||||
} from './stream.js';
|
} from './stream.js';
|
||||||
|
|
||||||
describe('geminiGenerateContentTransformer.inbound', () => {
|
describe('geminiGenerateContentTransformer.inbound', () => {
|
||||||
|
it('reuses the same gemini url resolver helpers across transformer layers', () => {
|
||||||
|
expect(geminiGenerateContentTransformer.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl);
|
||||||
|
expect(geminiGenerateContentTransformer.resolveModelsUrl).toBe(resolveGeminiModelsUrl);
|
||||||
|
expect(geminiGenerateContentTransformer.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl);
|
||||||
|
expect(geminiGenerateContentOutbound.resolveBaseUrl).toBe(resolveGeminiNativeBaseUrl);
|
||||||
|
expect(geminiGenerateContentOutbound.resolveModelsUrl).toBe(resolveGeminiModelsUrl);
|
||||||
|
expect(geminiGenerateContentOutbound.resolveActionUrl).toBe(resolveGeminiGenerateContentUrl);
|
||||||
|
});
|
||||||
|
|
||||||
it('parses native Gemini requests into canonical envelopes', () => {
|
it('parses native Gemini requests into canonical envelopes', () => {
|
||||||
const result = geminiGenerateContentTransformer.parseRequest({
|
const result = geminiGenerateContentTransformer.parseRequest({
|
||||||
model: 'gemini-2.5-pro',
|
model: 'gemini-2.5-pro',
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js';
|
import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js';
|
||||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
import {
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
resolveGeminiGenerateContentUrl,
|
||||||
}
|
resolveGeminiModelsUrl,
|
||||||
|
resolveGeminiNativeBaseUrl,
|
||||||
function baseIncludesVersion(baseUrl: string): boolean {
|
} from './urlResolver.js';
|
||||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
export {
|
||||||
}
|
resolveGeminiGenerateContentUrl,
|
||||||
|
resolveGeminiModelsUrl,
|
||||||
|
resolveGeminiNativeBaseUrl,
|
||||||
|
} from './urlResolver.js';
|
||||||
|
|
||||||
function asTrimmedString(value: unknown): string {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -17,37 +20,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
|
||||||
const normalized = normalizeBaseUrl(baseUrl);
|
|
||||||
if (baseIncludesVersion(normalized)) return normalized;
|
|
||||||
return `${normalized}/${apiVersion}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGeminiModelsUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: string,
|
|
||||||
apiKey: string,
|
|
||||||
): string {
|
|
||||||
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
|
||||||
const separator = base.includes('?') ? '&' : '?';
|
|
||||||
return `${base}/models${separator}key=${encodeURIComponent(apiKey)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGeminiGenerateContentUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: string,
|
|
||||||
modelActionPath: string,
|
|
||||||
apiKey: string,
|
|
||||||
search: string,
|
|
||||||
): string {
|
|
||||||
const base = resolveGeminiNativeBaseUrl(baseUrl, apiVersion);
|
|
||||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
|
||||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
|
||||||
params.set('key', apiKey);
|
|
||||||
const query = params.toString();
|
|
||||||
return `${base}/${normalizedAction}${query ? `?${query}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string {
|
export function resolveGeminiProxyApiVersion(params: { geminiApiVersion?: unknown } | null | undefined): string {
|
||||||
return asTrimmedString(params?.geminiApiVersion) || 'v1beta';
|
return asTrimmedString(params?.geminiApiVersion) || 'v1beta';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,45 +7,11 @@ import {
|
|||||||
toTransformerMetadataRecord,
|
toTransformerMetadataRecord,
|
||||||
type TransformerMetadata,
|
type TransformerMetadata,
|
||||||
} from '../../shared/normalized.js';
|
} from '../../shared/normalized.js';
|
||||||
|
import {
|
||||||
function normalizeBaseUrl(baseUrl: string): string {
|
resolveGeminiGenerateContentUrl,
|
||||||
return (baseUrl || '').replace(/\/+$/, '');
|
resolveGeminiModelsUrl,
|
||||||
}
|
resolveGeminiNativeBaseUrl,
|
||||||
|
} from './urlResolver.js';
|
||||||
function baseIncludesVersion(baseUrl: string): boolean {
|
|
||||||
return /\/v\d+(?:beta)?(?:\/|$)/i.test(baseUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBaseUrl(baseUrl: string, apiVersion: string): string {
|
|
||||||
const normalized = normalizeBaseUrl(baseUrl);
|
|
||||||
if (baseIncludesVersion(normalized)) return normalized;
|
|
||||||
return `${normalized}/${apiVersion}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveModelsUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: string,
|
|
||||||
apiKey: string,
|
|
||||||
): string {
|
|
||||||
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
|
|
||||||
const separator = resolvedBaseUrl.includes('?') ? '&' : '?';
|
|
||||||
return `${resolvedBaseUrl}/models${separator}key=${encodeURIComponent(apiKey)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveActionUrl(
|
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: string,
|
|
||||||
modelActionPath: string,
|
|
||||||
apiKey: string,
|
|
||||||
search: string,
|
|
||||||
): string {
|
|
||||||
const resolvedBaseUrl = resolveBaseUrl(baseUrl, apiVersion);
|
|
||||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
|
||||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
|
||||||
params.set('key', apiKey);
|
|
||||||
const query = params.toString();
|
|
||||||
return `${resolvedBaseUrl}/${normalizedAction}${query ? `?${query}` : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeminiRecord = Record<string, unknown>;
|
type GeminiRecord = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -304,9 +270,9 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const geminiGenerateContentOutbound = {
|
export const geminiGenerateContentOutbound = {
|
||||||
resolveBaseUrl,
|
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||||
resolveModelsUrl,
|
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||||
resolveActionUrl,
|
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||||
extractTransformerMetadata,
|
extractTransformerMetadata,
|
||||||
extractResponseMetadata,
|
extractResponseMetadata,
|
||||||
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
serializeAggregateResponse: serializeGeminiAggregateResponse,
|
||||||
|
|||||||
@@ -0,0 +1,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}` : ''}`;
|
||||||
|
}
|
||||||
+16
@@ -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<ConversationFileKind, 'unknown'>;
|
||||||
|
export declare function detectConversationFileKind(file: {
|
||||||
|
filename?: string | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
}): ConversationFileKind;
|
||||||
|
export declare function isSupportedConversationFileMimeType(mimeType: string): boolean;
|
||||||
|
export declare function buildConversationAcceptList(input: {
|
||||||
|
document: boolean;
|
||||||
|
image: boolean;
|
||||||
|
audio: boolean;
|
||||||
|
}): string;
|
||||||
@@ -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(',');
|
||||||
|
}
|
||||||
@@ -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/*');
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
export declare const PLATFORM_ALIASES: Record<string, string>;
|
||||||
|
export declare function normalizePlatformAlias(platform: unknown): string;
|
||||||
|
export declare function detectPlatformByUrlHint(url: string): string | undefined;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+9
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-21
@@ -19,9 +19,9 @@ import { clearAppInstallationState } from '../appLocalState.js';
|
|||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
import {
|
import {
|
||||||
isExactModelPattern,
|
isExactModelPattern,
|
||||||
parseBrandIconValue,
|
resolveRouteBrand,
|
||||||
|
resolveRouteIcon,
|
||||||
resolveRouteTitle,
|
resolveRouteTitle,
|
||||||
ROUTE_ICON_NONE_VALUE,
|
|
||||||
} from './token-routes/utils.js';
|
} from './token-routes/utils.js';
|
||||||
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||||
|
|
||||||
@@ -178,12 +178,6 @@ function inferUrlDialect(connectionString: string): 'mysql' | 'postgres' | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRouteBrandSource(route: RouteSelectorItem): string {
|
|
||||||
const title = resolveRouteTitle(route);
|
|
||||||
if (getBrand(title)) return title;
|
|
||||||
return route.modelPattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -1907,9 +1901,9 @@ export default function Settings() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
|
||||||
) : filteredGroupRouteOptions.map((route) => {
|
) : filteredGroupRouteOptions.map((route) => {
|
||||||
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
|
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
|
||||||
const explicitBrandIcon = parseBrandIconValue(route.displayIcon || '');
|
const routeTitle = resolveRouteTitle(route);
|
||||||
const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE;
|
const routeIcon = resolveRouteIcon(route);
|
||||||
const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim();
|
const routeBrand = resolveRouteBrand(route);
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={route.id}
|
key={route.id}
|
||||||
@@ -1963,21 +1957,23 @@ export default function Settings() {
|
|||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{explicitBrandIcon ? (
|
{routeIcon.kind === 'brand' ? (
|
||||||
<BrandGlyph
|
<BrandGlyph
|
||||||
icon={explicitBrandIcon}
|
icon={routeIcon.value}
|
||||||
alt={resolveRouteTitle(route)}
|
alt={routeTitle}
|
||||||
size={18}
|
size={18}
|
||||||
fallbackText={resolveRouteTitle(route)}
|
fallbackText={routeTitle}
|
||||||
/>
|
/>
|
||||||
) : textIcon ? (
|
) : routeIcon.kind === 'text' ? (
|
||||||
textIcon
|
routeIcon.value
|
||||||
) : explicitNoIcon ? null : (
|
) : routeIcon.kind === 'auto' && routeBrand ? (
|
||||||
<InlineBrandIcon model={resolveRouteBrandSource(route)} size={18} />
|
<BrandGlyph brand={routeBrand} alt={routeTitle} size={18} fallbackText={routeTitle} />
|
||||||
)}
|
) : routeIcon.kind === 'auto' ? (
|
||||||
|
<InlineBrandIcon model={route.modelPattern} size={18} />
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
{resolveRouteTitle(route)}
|
{routeTitle}
|
||||||
</span>
|
</span>
|
||||||
{!route.enabled && (
|
{!route.enabled && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { PlaygroundProtocol } from './modelTesterSession.js';
|
import type { PlaygroundProtocol } from './modelTesterSession.js';
|
||||||
|
import {
|
||||||
|
buildConversationAcceptList,
|
||||||
|
detectConversationFileKind,
|
||||||
|
} from '../../../shared/conversationFileTypes.js';
|
||||||
|
|
||||||
export type ConversationFileTransportMode = 'native' | 'inline_only' | 'unsupported';
|
export type ConversationFileTransportMode = 'native' | 'inline_only' | 'unsupported';
|
||||||
|
|
||||||
@@ -10,44 +14,13 @@ export type ConversationFileCapability = {
|
|||||||
reason: string;
|
reason: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOCUMENT_ACCEPT_PARTS = ['.pdf', '.txt', '.md', '.markdown', '.json'] as const;
|
|
||||||
const DOCUMENT_MIME_TYPES = new Set([
|
|
||||||
'application/json',
|
|
||||||
'application/pdf',
|
|
||||||
'text/markdown',
|
|
||||||
'text/plain',
|
|
||||||
]);
|
|
||||||
const DOCUMENT_EXTENSIONS = ['.json', '.md', '.markdown', '.pdf', '.txt'] as const;
|
|
||||||
const IMAGE_EXTENSIONS = ['.avif', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp'] as const;
|
|
||||||
const AUDIO_EXTENSIONS = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.wav', '.weba'] as const;
|
|
||||||
|
|
||||||
type ConversationFileDescriptor = {
|
type ConversationFileDescriptor = {
|
||||||
filename?: string | null;
|
filename?: string | null;
|
||||||
mimeType?: string | null;
|
mimeType?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConversationFileKind = 'document' | 'image' | 'audio' | 'unknown';
|
|
||||||
|
|
||||||
const isSupportedMode = (mode: ConversationFileTransportMode): boolean => mode !== 'unsupported';
|
const isSupportedMode = (mode: ConversationFileTransportMode): boolean => mode !== 'unsupported';
|
||||||
|
|
||||||
const normalizeName = (value: string | null | undefined): string => (value || '').trim().toLowerCase();
|
|
||||||
|
|
||||||
function detectKindFromFilename(filename: string): ConversationFileKind {
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveConversationFileKind(file: ConversationFileDescriptor): ConversationFileKind {
|
|
||||||
const mimeType = normalizeName(file.mimeType);
|
|
||||||
if (mimeType.startsWith('image/')) return 'image';
|
|
||||||
if (mimeType.startsWith('audio/')) return 'audio';
|
|
||||||
if (DOCUMENT_MIME_TYPES.has(mimeType)) return 'document';
|
|
||||||
return detectKindFromFilename(normalizeName(file.filename));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSupportedTypeLabel(capability: ConversationFileCapability): string {
|
function buildSupportedTypeLabel(capability: ConversationFileCapability): string {
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
if (isSupportedMode(capability.documentMode)) labels.push('PDF / TXT / Markdown / JSON');
|
if (isSupportedMode(capability.documentMode)) labels.push('PDF / TXT / Markdown / JSON');
|
||||||
@@ -111,11 +84,11 @@ export function resolveConversationFileCapability(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildConversationFileAccept(capability: ConversationFileCapability): string {
|
export function buildConversationFileAccept(capability: ConversationFileCapability): string {
|
||||||
const parts: string[] = [];
|
return buildConversationAcceptList({
|
||||||
if (isSupportedMode(capability.documentMode)) parts.push(...DOCUMENT_ACCEPT_PARTS);
|
document: isSupportedMode(capability.documentMode),
|
||||||
if (isSupportedMode(capability.imageMode)) parts.push('image/*');
|
image: isSupportedMode(capability.imageMode),
|
||||||
if (isSupportedMode(capability.audioMode)) parts.push('audio/*');
|
audio: isSupportedMode(capability.audioMode),
|
||||||
return parts.join(',');
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildConversationFileHint(capability: ConversationFileCapability): string {
|
export function buildConversationFileHint(capability: ConversationFileCapability): string {
|
||||||
@@ -147,7 +120,7 @@ export function isConversationUploadedFileSupported(
|
|||||||
capability: ConversationFileCapability,
|
capability: ConversationFileCapability,
|
||||||
file: ConversationFileDescriptor,
|
file: ConversationFileDescriptor,
|
||||||
): boolean {
|
): boolean {
|
||||||
const kind = resolveConversationFileKind(file);
|
const kind = detectConversationFileKind(file);
|
||||||
if (kind === 'document') return isSupportedMode(capability.documentMode);
|
if (kind === 'document') return isSupportedMode(capability.documentMode);
|
||||||
if (kind === 'image') return isSupportedMode(capability.imageMode);
|
if (kind === 'image') return isSupportedMode(capability.imageMode);
|
||||||
if (kind === 'audio') return isSupportedMode(capability.audioMode);
|
if (kind === 'audio') return isSupportedMode(capability.audioMode);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isTruthyFlag } from './accountConnection.js';
|
||||||
|
|
||||||
const FOCUS_SITE_ID_KEY = 'focusSiteId';
|
const FOCUS_SITE_ID_KEY = 'focusSiteId';
|
||||||
const FOCUS_ANNOUNCEMENT_ID_KEY = 'focusAnnouncementId';
|
const FOCUS_ANNOUNCEMENT_ID_KEY = 'focusAnnouncementId';
|
||||||
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
|
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
|
||||||
@@ -10,12 +12,6 @@ function normalizePositiveId(input: unknown): number | null {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTruthyFlag(value: string | null): boolean {
|
|
||||||
if (!value) return false;
|
|
||||||
const normalized = value.trim().toLowerCase();
|
|
||||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSiteFocusPath(siteId: number): string {
|
export function buildSiteFocusPath(siteId: number): string {
|
||||||
const normalizedId = normalizePositiveId(siteId);
|
const normalizedId = normalizePositiveId(siteId);
|
||||||
if (!normalizedId) return '/sites';
|
if (!normalizedId) return '/sites';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parseProxyLogMetadata } from '../../../shared/proxyLogMeta.js';
|
||||||
|
|
||||||
type ProxyLogPathMeta = {
|
type ProxyLogPathMeta = {
|
||||||
clientFamily: string | null;
|
clientFamily: string | null;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
@@ -8,20 +10,13 @@ type ProxyLogPathMeta = {
|
|||||||
|
|
||||||
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
|
export function parseProxyLogPathMeta(message?: string): ProxyLogPathMeta {
|
||||||
const raw = typeof message === 'string' ? message.trim() : '';
|
const raw = typeof message === 'string' ? message.trim() : '';
|
||||||
const clientMatch = raw.match(/\[client:([^\]]+)\]/i);
|
const parsed = parseProxyLogMetadata(raw);
|
||||||
const sessionMatch = raw.match(/\[session:([^\]]+)\]/i);
|
|
||||||
const downstreamMatch = raw.match(/\[downstream:([^\]]+)\]/i);
|
|
||||||
const upstreamMatch = raw.match(/\[upstream:([^\]]+)\]/i);
|
|
||||||
const stripped = raw.replace(
|
|
||||||
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
|
|
||||||
'',
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
clientFamily: clientMatch?.[1]?.trim() || null,
|
clientFamily: parsed.clientKind,
|
||||||
sessionId: sessionMatch?.[1]?.trim() || null,
|
sessionId: parsed.sessionId,
|
||||||
downstreamPath: downstreamMatch?.[1]?.trim() || null,
|
downstreamPath: parsed.downstreamPath,
|
||||||
upstreamPath: upstreamMatch?.[1]?.trim() || null,
|
upstreamPath: parsed.upstreamPath,
|
||||||
errorMessage: stripped,
|
errorMessage: parsed.messageText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user