diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 900833f..ab951ff 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -182,6 +182,8 @@ function ensureTokenManagementSchema() { WHERE a.api_token IS NOT NULL AND trim(a.api_token) <> '' + AND a.access_token IS NOT NULL + AND trim(a.access_token) <> '' AND NOT EXISTS ( SELECT 1 FROM account_tokens AS t WHERE t.account_id = a.id diff --git a/src/server/index.ts b/src/server/index.ts index 8aa3dc0..c6b388d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,6 +22,7 @@ import { oauthRoutes } from './routes/api/oauth.js'; import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; +import { rebuildTokenRoutesFromAvailability } from './services/modelService.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; @@ -329,6 +330,7 @@ try { await repairStoredCreatedAtValues(); await migrateSiteApiKeysToAccounts(); await ensureDefaultSitesSeeded(); + await rebuildTokenRoutesFromAvailability(); console.log('Loaded runtime settings overrides'); } catch (error) { diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts index 6d00c02..ae8992b 100644 --- a/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts +++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts @@ -40,15 +40,15 @@ describe('conversationFileCapabilities', () => { }); }); - it('describes inline-only document support for claude messages and gemini chat paths', () => { + it('describes native remote-document support for claude messages and inline-only support for gemini chat paths', () => { expect(resolveConversationFileEndpointCapability({ sitePlatform: 'claude', endpoint: 'messages', })).toMatchObject({ image: 'native', audio: 'unsupported', - document: 'inline_only', - preservesRemoteDocumentUrl: false, + document: 'native', + preservesRemoteDocumentUrl: true, }); expect(resolveConversationFileEndpointCapability({ diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts index d2d22fc..38a7cfc 100644 --- a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts +++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts @@ -151,8 +151,8 @@ export function resolveConversationFileEndpointCapability(input: { return { image: 'native', audio: 'unsupported', - document: 'inline_only', - preservesRemoteDocumentUrl: false, + document: 'native', + preservesRemoteDocumentUrl: true, }; } return { diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index d64ecaf..bb3e20f 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { buildClaudeCountTokensUpstreamRequest, @@ -24,6 +24,7 @@ import { import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; @@ -164,6 +165,7 @@ export async function handleChatSurfaceRequest( requestedModelHint: requestedModel, requestCapabilities: { hasNonImageFileInput, + conversationFileSummary, }, }; const buildProviderHeaders = () => ( @@ -241,7 +243,7 @@ export async function handleChatSurfaceRequest( extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, }; const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); - const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; + const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path); const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); if (refreshedResponse.ok) { return { @@ -263,7 +265,6 @@ export async function handleChatSurfaceRequest( try { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index 462b981..bbeb0be 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -33,6 +33,7 @@ import { import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; import { insertProxyLog } from '../../services/proxyLogStore.js'; +import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; const MAX_RETRIES = 2; const GEMINI_MODEL_PROBES = [ @@ -685,6 +686,8 @@ export async function geminiProxyRoute(app: FastifyInstance) { modelName: actualModel, stream: isStreamAction, }); + const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody); + const hasNonImageFileInput = conversationFileSummary.hasDocument; const endpointCandidates = await resolveUpstreamEndpointCandidates( { site: selected.site, @@ -693,12 +696,20 @@ export async function geminiProxyRoute(app: FastifyInstance) { actualModel, 'openai', requestedModel, + { + hasNonImageFileInput, + conversationFileSummary, + }, ); const endpointRuntimeContext = { siteId: selected.site.id, modelName: actualModel, downstreamFormat: 'openai' as const, requestedModelHint: requestedModel, + requestCapabilities: { + hasNonImageFileInput, + conversationFileSummary, + }, }; const buildEndpointRequest = ( endpoint: 'chat' | 'messages' | 'responses', diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 4ed49ae..3f07437 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; -import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; import { buildUpstreamEndpointRequest, @@ -19,6 +19,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; +import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; @@ -237,14 +238,6 @@ export async function handleOpenAiResponsesSurfaceRequest( const responsesConversationFileSummary = summarizeConversationFileInputsInResponsesBody(normalizedResponsesBody); const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl || carriesResponsesFileUrlInput(normalizedResponsesBody.input); - if (requiresNativeResponsesFileUrl && String(selected.site.platform || '').trim().toLowerCase() === 'claude') { - return reply.code(400).send({ - error: { - message: 'Responses input_file.file_url requires an upstream /v1/responses endpoint; current site only supports /v1/messages.', - type: 'invalid_request_error', - }, - }); - } const endpointCandidates = await resolveUpstreamEndpointCandidates( { site: selected.site, @@ -259,9 +252,6 @@ export async function handleOpenAiResponsesSurfaceRequest( wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, }, ); - if (requiresNativeResponsesFileUrl) { - endpointCandidates.splice(0, endpointCandidates.length, 'responses'); - } if (endpointCandidates.length === 0) { endpointCandidates.push('responses', 'chat', 'messages'); } @@ -272,6 +262,7 @@ export async function handleOpenAiResponsesSurfaceRequest( requestedModelHint: requestedModel, requestCapabilities: { hasNonImageFileInput, + conversationFileSummary, wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, }, }; @@ -341,7 +332,7 @@ export async function handleOpenAiResponsesSurfaceRequest( extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, }; const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); - const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; + const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path); const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); if (refreshedResponse.ok) { return { @@ -364,7 +355,6 @@ export async function handleOpenAiResponsesSurfaceRequest( try { const endpointResult = await executeEndpointFlow({ siteUrl: selected.site.url, - proxyUrl: resolveProxyUrlForSite(selected.site), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), dispatchRequest, diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index 423db26..78afe1e 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -22,6 +22,10 @@ function validateKeyShape(key: string): boolean { function looksLikeUniqueViolation(error: unknown): boolean { const message = (error as Error | undefined)?.message || ''; + if (runtimeDbDialect === 'postgres') { + return message.includes('duplicate key value violates unique constraint') && + message.includes('downstream_api_keys_key_unique'); + } return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key'); } diff --git a/src/server/routes/api/settings.system-proxy-test.test.ts b/src/server/routes/api/settings.system-proxy-test.test.ts new file mode 100644 index 0000000..0eaaa8c --- /dev/null +++ b/src/server/routes/api/settings.system-proxy-test.test.ts @@ -0,0 +1,123 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const fetchMock = vi.fn(); + +vi.mock('undici', async () => { + const actual = await vi.importActual('undici'); + return { + ...actual, + fetch: (...args: unknown[]) => fetchMock(...args), + }; +}); + +type ConfigModule = typeof import('../../config.js'); + +describe('settings system proxy test route', () => { + let app: FastifyInstance; + let config: ConfigModule['config']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-system-proxy-test-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const configModule = await import('../../config.js'); + const settingsRoutesModule = await import('./settings.js'); + + config = configModule.config; + + app = Fastify(); + await app.register(settingsRoutesModule.settingsRoutes); + }); + + beforeEach(() => { + fetchMock.mockReset(); + config.systemProxyUrl = ''; + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('tests the provided system proxy url and returns latency', async () => { + fetchMock.mockResolvedValue(new Response(null, { + status: 204, + headers: { 'content-type': 'text/plain' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: { + proxyUrl: 'http://127.0.0.1:7890', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + proxyUrl: 'http://127.0.0.1:7890', + probeUrl: 'https://www.gstatic.com/generate_204', + finalUrl: 'https://www.gstatic.com/generate_204', + reachable: true, + ok: true, + statusCode: 204, + }); + expect((response.json() as { latencyMs?: number }).latencyMs).toBeGreaterThanOrEqual(1); + + const [url, requestInit] = fetchMock.mock.calls[0] as [string, Record]; + expect(url).toBe('https://www.gstatic.com/generate_204'); + expect(requestInit.method).toBe('GET'); + expect(requestInit.dispatcher).toBeTruthy(); + }); + + it('uses the saved system proxy url when request body is empty', async () => { + config.systemProxyUrl = 'socks5://127.0.0.1:1080'; + fetchMock.mockResolvedValue(new Response(null, { status: 204 })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: {}, + }); + + expect(response.statusCode).toBe(200); + expect((response.json() as { proxyUrl?: string }).proxyUrl).toBe('socks5://127.0.0.1:1080'); + }); + + it('rejects missing system proxy url', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: {}, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { message?: string }).message).toContain('请先填写系统代理地址'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 502 when the proxy probe request fails', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed', { + cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'), + })); + + const response = await app.inject({ + method: 'POST', + url: '/api/settings/system-proxy/test', + payload: { + proxyUrl: 'http://127.0.0.1:7890', + }, + }); + + expect(response.statusCode).toBe(502); + expect((response.json() as { message?: string }).message).toContain('连接被拒绝'); + expect((response.json() as { message?: string }).message).not.toContain('fetch failed'); + }); +}); diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index d740fb0..d39b142 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -1,5 +1,6 @@ import { FastifyInstance } from 'fastify'; import cron from 'node-cron'; +import { fetch } from 'undici'; import { config } from '../../config.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; @@ -17,7 +18,7 @@ import { } from '../../services/databaseMigrationService.js'; import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js'; import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; -import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js'; +import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { performFactoryReset } from '../../services/factoryResetService.js'; import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js'; import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js'; @@ -70,6 +71,10 @@ interface DatabaseMigrationBody { ssl?: unknown; } +interface SystemProxyTestBody { + proxyUrl?: unknown; +} + type RuntimeDatabaseConfig = { dialect: MigrationDialect; connectionString: string; @@ -80,6 +85,8 @@ const PROXY_TOKEN_PREFIX = 'sk-'; const DB_TYPE_SETTING_KEY = 'db_type'; const DB_URL_SETTING_KEY = 'db_url'; const DB_SSL_SETTING_KEY = 'db_ssl'; +const SYSTEM_PROXY_TEST_PROBE_URL = 'https://www.gstatic.com/generate_204'; +const SYSTEM_PROXY_TEST_TIMEOUT_MS = 15_000; function isValidProxyToken(value: string): boolean { return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6; @@ -118,6 +125,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) { return n; } +function extractNestedErrorMessages(error: unknown): string[] { + const messages: string[] = []; + const visited = new Set(); + let current: any = error; + + while (current && !visited.has(current)) { + visited.add(current); + const message = typeof current?.message === 'string' ? current.message.trim() : ''; + if (message) { + messages.push(message); + } + current = current?.cause; + } + + return messages; +} + +function describeSystemProxyTestFailure(error: unknown): string { + const messages = extractNestedErrorMessages(error); + const detail = messages.find((message) => message && message !== 'fetch failed') + || messages[0] + || '未知错误'; + + if (/ECONNREFUSED/i.test(detail)) { + return '系统代理测试失败:连接被拒绝,请检查代理地址、端口和本地代理程序是否已启动'; + } + + if (/ETIMEDOUT|timed out|timeout/i.test(detail)) { + return '系统代理测试失败:连接超时,请检查代理服务或当前网络是否可用'; + } + + if (/ENOTFOUND|EAI_AGAIN/i.test(detail)) { + return '系统代理测试失败:域名解析失败,请检查网络或代理的 DNS 配置'; + } + + if (/ECONNRESET/i.test(detail)) { + return '系统代理测试失败:连接被对端重置,请检查代理链路是否稳定'; + } + + if (/407/.test(detail) || /proxy authentication/i.test(detail)) { + return '系统代理测试失败:代理要求认证,请检查用户名、密码或代理配置'; + } + + return `系统代理测试失败:${detail}`; +} + +async function testSystemProxyConnectivity(proxyUrl: string) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SYSTEM_PROXY_TEST_TIMEOUT_MS); + const startedAt = Date.now(); + + try { + const response = await fetch( + SYSTEM_PROXY_TEST_PROBE_URL, + withExplicitProxyRequestInit(proxyUrl, { + method: 'GET', + signal: controller.signal, + headers: { + 'cache-control': 'no-cache', + 'user-agent': 'metapi-system-proxy-tester/1.0', + }, + }), + ); + + try { + await response.arrayBuffer(); + } catch { + // Ignore body drain failures; reachability is determined by receiving a response. + } + + return { + reachable: true, + ok: response.ok, + statusCode: response.status, + latencyMs: Math.max(1, Date.now() - startedAt), + probeUrl: SYSTEM_PROXY_TEST_PROBE_URL, + finalUrl: response.url || SYSTEM_PROXY_TEST_PROBE_URL, + }; + } catch (error: any) { + if (error?.name === 'AbortError') { + throw new Error(`系统代理测试超时(${Math.round(SYSTEM_PROXY_TEST_TIMEOUT_MS / 1000)}s)`); + } + throw new Error(describeSystemProxyTestFailure(error)); + } finally { + clearTimeout(timeout); + } +} + function toStringList(value: unknown): string[] { if (Array.isArray(value)) { return value @@ -524,6 +619,43 @@ export async function settingsRoutes(app: FastifyInstance) { return getRuntimeSettingsResponse(currentAdminIp); }); + app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => { + const rawProxyUrl = request.body?.proxyUrl === undefined + ? config.systemProxyUrl + : String(request.body.proxyUrl || '').trim(); + const normalizedProxyUrl = rawProxyUrl + ? normalizeSiteProxyUrl(rawProxyUrl) + : ''; + + if (!rawProxyUrl) { + return reply.code(400).send({ + success: false, + message: '请先填写系统代理地址', + }); + } + + if (!normalizedProxyUrl) { + return reply.code(400).send({ + success: false, + message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL', + }); + } + + try { + const result = await testSystemProxyConnectivity(normalizedProxyUrl); + return { + success: true, + proxyUrl: normalizedProxyUrl, + ...result, + }; + } catch (error: any) { + return reply.code(502).send({ + success: false, + message: error?.message || '系统代理测试失败', + }); + } + }); + app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => { const body = request.body || {}; const changedLabels: string[] = []; diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index 428544c..ae38ffb 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs'; type DbModule = typeof import('../../db/index.js'); -describe('sites system proxy settings', () => { +describe('sites proxy settings', () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; @@ -36,7 +36,7 @@ describe('sites system proxy settings', () => { delete process.env.DATA_DIR; }); - it('stores useSystemProxy, external checkin url, and custom headers when creating a site', async () => { + it('stores proxy settings, external checkin url, and custom headers when creating a site', async () => { const response = await app.inject({ method: 'POST', url: '/api/sites', @@ -44,6 +44,7 @@ describe('sites system proxy settings', () => { name: 'proxy-site', url: 'https://proxy-site.example.com', platform: 'new-api', + proxyUrl: 'socks5://127.0.0.1:1080', useSystemProxy: true, customHeaders: JSON.stringify({ 'cf-access-client-id': 'site-client-id', @@ -56,11 +57,13 @@ describe('sites system proxy settings', () => { expect(response.statusCode).toBe(200); const payload = response.json() as { + proxyUrl?: string | null; useSystemProxy?: boolean; customHeaders?: string | null; externalCheckinUrl?: string | null; globalWeight?: number; }; + expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080'); expect(payload.useSystemProxy).toBe(true); expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}'); expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare'); @@ -83,6 +86,22 @@ describe('sites system proxy settings', () => { expect((response.json() as { error?: string }).error).toContain('Invalid useSystemProxy'); }); + it('rejects invalid proxy url', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'proxy-site', + url: 'https://proxy-site.example.com', + platform: 'new-api', + proxyUrl: 'not-a-proxy', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl'); + }); + it('rejects invalid site global weight', async () => { const response = await app.inject({ method: 'POST', @@ -115,7 +134,7 @@ describe('sites system proxy settings', () => { expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl'); }); - it('updates useSystemProxy for an existing site', async () => { + it('updates per-site proxy settings for an existing site', async () => { const created = await app.inject({ method: 'POST', url: '/api/sites', @@ -133,12 +152,15 @@ describe('sites system proxy settings', () => { method: 'PUT', url: `/api/sites/${site.id}`, payload: { + proxyUrl: 'http://127.0.0.1:8080', useSystemProxy: true, }, }); expect(response.statusCode).toBe(200); - expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true); + const payload = response.json() as { proxyUrl?: string | null; useSystemProxy?: boolean }; + expect(payload.proxyUrl).toBe('http://127.0.0.1:8080'); + expect(payload.useSystemProxy).toBe(true); }); it('rejects invalid custom headers json', async () => { diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index d433b8a..294379d 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, eq } from 'drizzle-orm'; import { detectSite } from '../../services/siteDetector.js'; -import { invalidateSiteProxyCache } from '../../services/siteProxy.js'; +import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { invalidateTokenRouterCache } from '../../services/tokenRouter.js'; import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js'; @@ -222,6 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) { name: string; url: string; platform?: string; + proxyUrl?: string | null; useSystemProxy?: boolean; customHeaders?: string | null; externalCheckinUrl?: string | null; @@ -230,7 +231,7 @@ export async function sitesRoutes(app: FastifyInstance) { sortOrder?: number; globalWeight?: number; } }>('/api/sites', async (request, reply) => { - const { name, url, platform, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; + const { name, url, platform, proxyUrl, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; const normalizedStatus = normalizeSiteStatus(status); if (status !== undefined && !normalizedStatus) { return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); @@ -239,6 +240,10 @@ export async function sitesRoutes(app: FastifyInstance) { if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) { return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' }); } + const normalizedProxyUrl = parseSiteProxyUrlInput(proxyUrl); + if (!normalizedProxyUrl.valid) { + return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' }); + } const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl); if (!normalizedExternalCheckinUrl.valid) { return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' }); @@ -275,6 +280,7 @@ export async function sitesRoutes(app: FastifyInstance) { name, url: url.replace(/\/+$/, ''), platform: detectedPlatform, + proxyUrl: normalizedProxyUrl.proxyUrl, useSystemProxy: normalizedUseSystemProxy ?? false, customHeaders: normalizedCustomHeaders.customHeaders, externalCheckinUrl: normalizedExternalCheckinUrl.url, @@ -300,6 +306,7 @@ export async function sitesRoutes(app: FastifyInstance) { name?: string; url?: string; platform?: string; + proxyUrl?: string | null; useSystemProxy?: boolean; customHeaders?: string | null; externalCheckinUrl?: string | null; @@ -328,6 +335,10 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) { return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' }); } + const normalizedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl); + if (!normalizedProxyUrl.valid) { + return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' }); + } const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl); if (!normalizedExternalCheckinUrl.valid) { return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' }); @@ -352,6 +363,7 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.name !== undefined) updates.name = body.name; if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); if (body.platform !== undefined) updates.platform = body.platform; + if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy; if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders; if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url; diff --git a/src/server/routes/proxy/chat.codex-oauth.test.ts b/src/server/routes/proxy/chat.codex-oauth.test.ts index 2f538ee..f561f87 100644 --- a/src/server/routes/proxy/chat.codex-oauth.test.ts +++ b/src/server/routes/proxy/chat.codex-oauth.test.ts @@ -14,6 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => estimatedCostFromQuota: 0, recoveredFromSelfLog: false, })); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); const dbInsertMock = vi.fn((_arg?: any) => ({ values: () => ({ run: () => undefined, @@ -60,6 +61,10 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({ resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), })); +vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), +})); + vi.mock('../../db/index.js', () => ({ db: { insert: (arg: any) => dbInsertMock(arg), @@ -107,6 +112,7 @@ describe('chat proxy codex oauth compatibility', () => { reportProxyAllFailedMock.mockReset(); reportTokenExpiredMock.mockReset(); resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + refreshOauthAccessTokenSingleflightMock.mockReset(); dbInsertMock.mockClear(); selectChannelMock.mockReturnValue({ @@ -130,6 +136,11 @@ describe('chat proxy codex oauth compatibility', () => { actualModel: 'gpt-5.4', }); selectNextChannelMock.mockReturnValue(null); + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'fresh-access-token', + accountId: 33, + accountKey: 'chatgpt-account-123', + }); }); afterAll(async () => { @@ -281,4 +292,73 @@ describe('chat proxy codex oauth compatibility', () => { expect(response.body).not.toContain('event: response.created'); expect(response.body).not.toContain('"type":"response.output_text.delta"'); }); + + it('retries oauth chat requests with a normalized upstream URL after refresh', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' }, + account: { + id: 33, + username: 'oauth-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'oauth-user@example.com', + planType: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-4o-mini', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'chatcmpl_refreshed', + object: 'chat.completion', + created: 1706000000, + model: 'gpt-4o-mini', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ok after refresh' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-4o-mini', + messages: [{ role: 'user', content: 'hello oauth' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://gateway.example.com/v1/responses'); + expect(secondUrl).toBe('https://gateway.example.com/v1/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(response.json()?.choices?.[0]?.message?.content).toBe('ok after refresh'); + }); }); diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 0c1a124..f478029 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -2032,6 +2032,87 @@ describe('chat proxy stream behavior', () => { expect(targetUrl).toContain('/v1/messages'); }); + it('does not stick generic /v1/responses traffic to /v1/messages after a fallback success', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'Gateway time-out', type: 'upstream_error' }, + }), { + status: 504, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'Bad gateway', type: 'upstream_error' }, + }), { + status: 502, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'msg_fallback_1', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'ok via messages fallback' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_recovered_1', + object: 'response', + model: 'upstream-gpt', + status: 'completed', + output_text: 'ok via recovered responses', + usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const firstResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello', + }, + }); + + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.json().output_text).toContain('ok via messages fallback'); + + const secondResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello again', + }, + }); + + expect(secondResponse.statusCode).toBe(200); + expect(secondResponse.json().output_text).toContain('ok via recovered responses'); + expect(fetchMock).toHaveBeenCalledTimes(4); + + const [firstUrl] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl] = fetchMock.mock.calls[1] as [string, any]; + const [thirdUrl] = fetchMock.mock.calls[2] as [string, any]; + const [fourthUrl] = fetchMock.mock.calls[3] as [string, any]; + expect(firstUrl).toContain('/v1/responses'); + expect(secondUrl).toContain('/v1/chat/completions'); + expect(thirdUrl).toContain('/v1/messages'); + expect(fourthUrl).toContain('/v1/responses'); + }); + it('prefers native /v1/responses for claude-family /v1/responses requests that explicitly ask for encrypted reasoning', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -2209,7 +2290,7 @@ describe('chat proxy stream behavior', () => { }); }); - it('returns clear 400 when input_file file_url is sent to a claude-only upstream without native responses support', async () => { + it('converts input_file file_url into Claude document url blocks for claude-only upstreams', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' }, @@ -2218,6 +2299,18 @@ describe('chat proxy stream behavior', () => { tokenValue: 'sk-demo', actualModel: 'upstream-claude', }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_file_url_1', + type: 'message', + role: 'assistant', + model: 'upstream-claude', + content: [{ type: 'text', text: 'hello from claude messages' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); const response = await app.inject({ method: 'POST', @@ -2241,12 +2334,119 @@ describe('chat proxy stream behavior', () => { }, }); - expect(response.statusCode).toBe(400); - const body = response.json(); - expect(body?.error?.type).toBe('invalid_request_error'); - expect(body?.error?.message).toContain('input_file.file_url'); - expect(body?.error?.message).toContain('/v1/responses'); - expect(fetchMock).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.messages[0].content[1]).toMatchObject({ + type: 'document', + title: 'remote.pdf', + source: { + type: 'url', + url: 'https://example.com/remote.pdf', + }, + }); + }); + + it('does not let remote document url success poison later inline document endpoint preference', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + fetchMock.mockImplementation(async (target: unknown) => { + const url = String(target); + if (url.includes('/v1/responses')) { + return new Response(JSON.stringify({ + id: 'resp_file_url_runtime_1', + object: 'response', + model: 'upstream-gpt', + output_text: 'hello from responses upstream', + output: [ + { + id: 'msg_file_url_runtime_1', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'hello from responses upstream' }], + }, + ], + status: 'completed', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url.includes('/v1/messages')) { + return new Response(JSON.stringify({ + id: 'msg_inline_runtime_1', + type: 'message', + role: 'assistant', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'hello from messages upstream' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected target url: ${url}`); + }); + + const remoteResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'claude-haiku-4-5-20251001', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this remote file' }, + { + type: 'input_file', + filename: 'remote.pdf', + file_url: 'https://example.com/remote.pdf', + }, + ], + }, + ], + }, + }); + + expect(remoteResponse.statusCode).toBe(200); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/v1/responses'); + + const inlineResponse = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'claude-haiku-4-5-20251001', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'read this inline file' }, + { + type: 'input_file', + filename: 'brief.pdf', + file_data: 'data:application/pdf;base64,JVBERi0xLjQK', + }, + ], + }, + ], + }, + }); + + expect(inlineResponse.statusCode).toBe(200); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/v1/messages'); }); it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => { diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index 7513406..8dbd13a 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -73,6 +73,7 @@ describe('executeEndpointFlow', () => { expect(result.ok).toBe(true); expect(dispatchRequest).toHaveBeenCalledTimes(1); + expect(dispatchRequest.mock.calls[0]?.[1]).toBe('https://example.com/v1/responses'); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -219,22 +220,6 @@ describe('executeEndpointFlow', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('uses proxyUrl for the default fetch path when no dispatch hook is provided', async () => { - fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }))); - - await executeEndpointFlow({ - siteUrl: 'https://example.com', - proxyUrl: 'https://proxy.internal/base', - endpointCandidates: ['responses'], - buildRequest: () => requestFor('/v1/responses'), - }); - - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses'); - }); - it('returns normalized final error when all endpoints fail', async () => { fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ error: { message: 'upstream_error', type: 'upstream_error' }, diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index bd5b808..41588fa 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -59,7 +59,6 @@ export function withUpstreamPath(path: string, message: string): string { type ExecuteEndpointFlowInput = { siteUrl: string; - proxyUrl?: string | null; endpointCandidates: UpstreamEndpoint[]; buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest; dispatchRequest?: ( @@ -73,12 +72,6 @@ type ExecuteEndpointFlowInput = { onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise; }; -function buildAbsoluteUrl(base: string, path: string): string { - const normalizedBase = base.replace(/\/+$/, ''); - const normalizedPath = path.replace(/^\/+/, ''); - return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase; -} - export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise { const endpointCount = input.endpointCandidates.length; if (endpointCount <= 0) { @@ -96,10 +89,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) { const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint; const request = input.buildRequest(endpoint, endpointIndex); - const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path); - const targetUrl = input.proxyUrl - ? buildAbsoluteUrl(input.proxyUrl, request.path) - : defaultTarget; + const targetUrl = buildUpstreamUrl(input.siteUrl, request.path); let response = input.dispatchRequest ? await input.dispatchRequest(request, targetUrl) diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index 73e89c6..42931da 100644 --- a/src/server/routes/proxy/gemini.test.ts +++ b/src/server/routes/proxy/gemini.test.ts @@ -689,6 +689,92 @@ describe('gemini native proxy routes', () => { }); }); + it('routes Gemini native document requests to responses endpoints on openai-compatible upstreams', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 42, routeId: 22 }, + site: { id: 78, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 38, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockImplementation(async (target: unknown) => { + const url = String(target); + if (url === 'https://api.openai.com/v1/responses') { + return new Response(JSON.stringify({ + id: 'resp-openai-file-1', + object: 'response', + model: 'gpt-4.1', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'document summary from responses' }], + }, + ], + usage: { + input_tokens: 9, + output_tokens: 4, + total_tokens: 13, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url === 'https://api.openai.com/v1/chat/completions') { + return new Response(JSON.stringify({ + id: 'chatcmpl-openai-file-1', + object: 'chat.completion', + created: 1_742_160_002, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'document summary from chat', + }, + finish_reason: 'stop', + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected target url: ${url}`); + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [ + { text: 'summarize this pdf' }, + { + fileData: { + fileUri: 'https://example.com/brief.pdf', + mimeType: 'application/pdf', + }, + }, + ], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://api.openai.com/v1/responses'); + }); + it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => { selectChannelMock.mockReturnValue({ channel: { id: 43, routeId: 22 }, diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts index 5fed1c6..13187a6 100644 --- a/src/server/routes/proxy/responses.codex-oauth.test.ts +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -214,6 +214,69 @@ describe('responses proxy codex oauth refresh', () => { expect(response.json()?.output_text).toContain('ok after codex token refresh'); }); + it('retries oauth responses requests with a normalized upstream URL after refresh', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' }, + account: { + id: 33, + username: 'oauth-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'oauth-user@example.com', + planType: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-4.1-mini', + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_openai_refreshed', + object: 'response', + model: 'gpt-4.1-mini', + status: 'completed', + output_text: 'ok after refresh', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-4.1-mini', + input: 'hello oauth', + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://gateway.example.com/v1/responses'); + expect(secondUrl).toBe('https://gateway.example.com/v1/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(response.json()?.output_text).toBe('ok after refresh'); + }); + it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ id: 'resp_codex_no_system', diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 0d4ce8e..8492532 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -216,6 +216,87 @@ describe('resolveUpstreamEndpointCandidates', () => { expect(order).toEqual(['responses', 'chat', 'messages']); }); + it('keeps remote-document-url requests on a separate runtime preference bucket from inline document requests', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'chat', + downstreamFormat: 'openai', + modelName: 'gpt-5.3', + requestCapabilities: { + hasNonImageFileInput: true, + conversationFileSummary: { + hasImage: false, + hasAudio: false, + hasDocument: true, + hasRemoteDocumentUrl: false, + }, + }, + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'openai', + undefined, + { + hasNonImageFileInput: true, + conversationFileSummary: { + hasImage: false, + hasAudio: false, + hasDocument: true, + hasRemoteDocumentUrl: true, + }, + }, + ); + + expect(order).toEqual(['responses', 'messages', 'chat']); + }); + + it('does not remember messages fallback success for generic /v1/responses requests', async () => { + recordUpstreamEndpointSuccess({ + siteId: baseContext.site.id, + endpoint: 'messages', + downstreamFormat: 'responses', + modelName: 'gpt-5.3', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + + it('does not block generic /v1/responses endpoints on transient upstream errors', async () => { + recordUpstreamEndpointFailure({ + siteId: baseContext.site.id, + endpoint: 'responses', + downstreamFormat: 'responses', + modelName: 'gpt-5.3', + status: 504, + errorText: '{"error":{"message":"Gateway time-out","type":"upstream_error"}}', + }); + + const order = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'new-api' }, + }, + 'gpt-5.3', + 'responses', + ); + + expect(order).toEqual(['responses', 'chat', 'messages']); + }); + it('learns a better endpoint from explicit upstream protocol errors', async () => { recordUpstreamEndpointFailure({ siteId: baseContext.site.id, diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 87c5b90..9a7aec9 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -42,6 +42,7 @@ export type EndpointPreference = DownstreamFormat | 'responses'; type EndpointCapabilityProfile = { preferMessagesForClaudeModel: boolean; hasNonImageFileInput: boolean; + hasRemoteDocumentUrl: boolean; wantsNativeResponsesReasoning: boolean; }; @@ -619,6 +620,9 @@ function buildEndpointCapabilityProfile(input?: { input?.requestCapabilities?.conversationFileSummary?.hasDocument === true || input?.requestCapabilities?.hasNonImageFileInput === true ), + hasRemoteDocumentUrl: ( + input?.requestCapabilities?.conversationFileSummary?.hasRemoteDocumentUrl === true + ), wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true, }; } @@ -634,6 +638,7 @@ function buildEndpointRuntimeStateKey(input: { input.downstreamFormat, capabilityProfile.preferMessagesForClaudeModel ? 'claude' : 'generic', capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles', + capabilityProfile.hasRemoteDocumentUrl ? 'remoteurl' : 'noremoteurl', capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning', ].join(':'); } @@ -714,15 +719,37 @@ function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEnd function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean { if (isEndpointDispatchDeniedError(status, errorText)) return true; - if (isEndpointDowngradeError(status, errorText)) return true; + if (status === 404 || status === 405 || status === 415 || status === 501) return true; + if (isUnsupportedMediaTypeError(status, errorText)) return true; + const text = (errorText || '').toLowerCase(); return ( - text.includes('unsupported legacy protocol') + text.includes('convert_request_failed') + || text.includes('endpoint_not_found') + || text.includes('unknown_endpoint') + || text.includes('unsupported_endpoint') + || text.includes('unsupported_path') + || text.includes('not_found_error') + || text.includes('unsupported legacy protocol') || text.includes('please use /v1/') || text.includes('does not allow /v1/') + || text.includes('unknown endpoint') + || text.includes('unsupported endpoint') + || text.includes('unsupported path') + || text.includes('unrecognized request url') + || text.includes('no route matched') + || text.includes('does not exist') ); } +function shouldRememberSuccessfulEndpoint(input: { + endpoint: UpstreamEndpoint; + downstreamFormat: EndpointPreference; +}): boolean { + if (input.downstreamFormat !== 'responses') return true; + return input.endpoint === 'responses'; +} + export function resetUpstreamEndpointRuntimeState(): void { endpointRuntimeStates.clear(); } @@ -739,6 +766,8 @@ export function recordUpstreamEndpointSuccess(input: { wantsNativeResponsesReasoning?: boolean; }; }): void { + if (!shouldRememberSuccessfulEndpoint(input)) return; + const nowMs = Date.now(); const key = buildEndpointRuntimeStateKey({ siteId: input.siteId, diff --git a/src/server/services/downstreamApiKeyService.ts b/src/server/services/downstreamApiKeyService.ts index 105f6d0..88068db 100644 --- a/src/server/services/downstreamApiKeyService.ts +++ b/src/server/services/downstreamApiKeyService.ts @@ -90,13 +90,25 @@ function normalizePositiveIntegerOrNull(value: unknown): number | null { return normalized; } -function parseJson(value: string | null | undefined): unknown { - if (!value) return null; - try { - return JSON.parse(value); - } catch { - return null; +function parseJson(value: unknown): unknown { + if (value === null || value === undefined) return null; + + // PostgreSQL JSONB columns are returned as parsed objects/arrays by the pg driver + if (typeof value === 'object') { + return value; } + + // SQLite TEXT columns store JSON as strings that need parsing + if (typeof value === 'string') { + if (value === '') return null; + try { + return JSON.parse(value); + } catch { + return null; + } + } + + return null; } export function normalizeGroupNameInput(input: unknown): string | null { diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 904126c..9de6827 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -248,6 +248,55 @@ describe('refreshModelsForAccount credential discovery', () => { expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); }); + it('does not scan hidden managed tokens for direct apikey connections', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( + token === 'sk-direct-credential' ? ['gpt-4.1'] : ['legacy-should-not-be-used'] + )); + + const site = await db.insert(schema.sites).values({ + name: 'apikey-direct-site', + url: 'https://apikey-direct.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'apikey-direct-user', + accessToken: '', + apiToken: 'sk-direct-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + const hiddenToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'legacy-hidden', + token: 'sk-legacy-hidden', + source: 'legacy', + enabled: true, + isDefault: true, + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: 1, + modelsPreview: ['gpt-4.1'], + tokenScanned: 0, + discoveredByCredential: true, + }); + + const tokenRows = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, hiddenToken.id)) + .all(); + expect(tokenRows).toHaveLength(0); + }); + it('returns structured result when account missing', async () => { const result = await refreshModelsForAccount(9999); diff --git a/src/server/services/modelService.test.ts b/src/server/services/modelService.test.ts index 00b6e7d..e88d15e 100644 --- a/src/server/services/modelService.test.ts +++ b/src/server/services/modelService.test.ts @@ -85,6 +85,67 @@ describe('rebuildTokenRoutesFromAvailability', () => { expect(channels[0]?.manualOverride).toBe(false); }); + it('ignores hidden account_tokens for direct apikey connections when rebuilding routes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'apikey-legacy-site', + url: 'https://apikey-legacy.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'apikey-legacy-user', + accessToken: '', + apiToken: 'sk-direct-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + const hiddenToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'legacy-hidden', + token: 'sk-hidden-legacy-token', + source: 'legacy', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 200, + checkedAt: '2026-03-20T08:00:00.000Z', + }).run(); + + await db.insert(schema.tokenModelAvailability).values({ + tokenId: hiddenToken.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 180, + checkedAt: '2026-03-20T08:00:00.000Z', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-4.1')) + .get(); + expect(route).toBeDefined(); + + const channels = await db.select().from(schema.routeChannels) + .where(and( + eq(schema.routeChannels.routeId, route!.id), + eq(schema.routeChannels.accountId, account.id), + )) + .all(); + + expect(channels).toHaveLength(1); + expect(channels[0]?.tokenId ?? null).toBeNull(); + }); + it('creates an exact route with an account-direct channel for oauth model availability', async () => { const site = await db.insert(schema.sites).values({ name: 'codex-site', diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index d47ad94..321b171 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -10,8 +10,8 @@ import { isUsableAccountToken, } from './accountTokenService.js'; import { - getCredentialModeFromExtraConfig, mergeAccountExtraConfig, + requiresManagedAccountTokens, resolvePlatformUserId, supportsDirectAccountRoutingConnection, } from './accountExtraConfig.js'; @@ -141,12 +141,6 @@ function isSiteDisabled(status?: string | null): boolean { return (status || 'active') === 'disabled'; } -function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean { - const explicit = getCredentialModeFromExtraConfig(account.extraConfig); - if (explicit && explicit !== 'auto') return explicit === 'apikey'; - return !(account.accessToken || '').trim(); -} - function normalizeModels(models: string[]): string[] { const normalizedModels: string[] = []; const seen = new Set(); @@ -821,18 +815,21 @@ export async function refreshModelsForAccount(accountId: number): Promise isUsableAccountToken(row.account_tokens)); + const usableTokenRows = tokenRows.filter((row) => ( + isUsableAccountToken(row.account_tokens) + && requiresManagedAccountTokens(row.accounts) + )); const accountRows = await db.select().from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) diff --git a/src/server/services/siteProxy.test.ts b/src/server/services/siteProxy.test.ts index ec5258f..ae327e6 100644 --- a/src/server/services/siteProxy.test.ts +++ b/src/server/services/siteProxy.test.ts @@ -57,6 +57,22 @@ describe('siteProxy', () => { .toBeNull(); }); + it('prefers site-specific proxy url over the shared system proxy', async () => { + await db.insert(schema.settings).values({ + key: 'system_proxy_url', + value: JSON.stringify('http://127.0.0.1:7890'), + }).run(); + + await db.run(sql` + INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy) + VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'socks5://127.0.0.1:1080', 1) + `); + + const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js'); + expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/models')) + .toBe('socks5://127.0.0.1:1080'); + }); + it('injects dispatcher when a site opts into the configured system proxy', async () => { await db.insert(schema.settings).values({ key: 'system_proxy_url', @@ -75,6 +91,20 @@ describe('siteProxy', () => { expect('dispatcher' in requestInit).toBe(true); }); + it('injects dispatcher when a site defines its own proxy url', async () => { + await db.run(sql` + INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy) + VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'http://127.0.0.1:7890', 0) + `); + + const { withSiteProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', { + method: 'POST', + }); + + expect('dispatcher' in requestInit).toBe(true); + }); + it('injects a working dispatcher for socks5 system proxies', async () => { const upstreamServer = createServer((_request, response) => { response.writeHead(200, { 'content-type': 'application/json' }); @@ -170,6 +200,7 @@ describe('siteProxy', () => { it('merges site custom headers from site records even without cache lookup', async () => { const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js'); const requestInit = withSiteRecordProxyRequestInit({ + proxyUrl: 'http://127.0.0.1:7890', useSystemProxy: false, customHeaders: JSON.stringify({ 'x-site-scope': 'site-level', @@ -184,5 +215,6 @@ describe('siteProxy', () => { expect(headers.get('x-site-scope')).toBe('site-level'); expect(headers.get('x-request-id')).toBe('req-1'); + expect('dispatcher' in requestInit).toBe(true); }); }); diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index c7da18d..75b0949 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -31,6 +31,7 @@ const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000; type SiteProxyRow = { siteUrl: string; + proxyUrl: string | null; useSystemProxy: boolean; customHeaders: string | null; }; @@ -42,6 +43,7 @@ type ParsedSiteProxyInput = { }; type SiteProxyConfigLike = { + proxyUrl?: string | null; useSystemProxy?: boolean | null; customHeaders?: string | null; }; @@ -103,6 +105,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise ({ siteUrl: normalizeSiteUrl(row.siteUrl), + proxyUrl: normalizeSiteProxyUrl(row.proxyUrl), useSystemProxy: !!row.useSystemProxy, customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null, })), @@ -368,7 +372,8 @@ async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise const rows = await getCachedSiteProxyRows(); const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl); - const proxyUrl = matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null; + const proxyUrl = matchedRow?.proxyUrl + || (matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null); return { proxyUrl: proxyUrl || null, customHeaders: matchedRow?.customHeaders ?? null, @@ -425,6 +430,8 @@ export function withExplicitProxyRequestInit( } export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null { + const explicitProxyUrl = normalizeSiteProxyUrl(site?.proxyUrl); + if (explicitProxyUrl) return explicitProxyUrl; if (!site?.useSystemProxy) return null; return normalizeSiteProxyUrl(config.systemProxyUrl); } diff --git a/src/server/transformers/gemini/generate-content/compatibility.ts b/src/server/transformers/gemini/generate-content/compatibility.ts index fe4cde9..ab1b0d9 100644 --- a/src/server/transformers/gemini/generate-content/compatibility.ts +++ b/src/server/transformers/gemini/generate-content/compatibility.ts @@ -12,6 +12,10 @@ function asTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function isImageMimeType(mimeType: string): boolean { + return mimeType.toLowerCase().startsWith('image/'); +} + function parseJsonIfPossible(value: string): unknown { const trimmed = value.trim(); if (!trimmed) return {}; diff --git a/src/server/transformers/gemini/generate-content/index.test.ts b/src/server/transformers/gemini/generate-content/index.test.ts index b649c38..ac6dad4 100644 --- a/src/server/transformers/gemini/generate-content/index.test.ts +++ b/src/server/transformers/gemini/generate-content/index.test.ts @@ -50,6 +50,41 @@ describe('geminiGenerateContentTransformer.inbound', () => { }); }); + it('parses non-image inlineData parts into canonical file parts', () => { + const result = geminiGenerateContentTransformer.parseRequest({ + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [ + { text: 'summarize this pdf' }, + { + inlineData: { + mimeType: 'application/pdf', + data: 'JVBERi0xLjQK', + }, + }, + ], + }, + ], + }); + + expect(result.error).toBeUndefined(); + expect(result.value?.messages).toEqual([ + { + role: 'user', + parts: [ + { type: 'text', text: 'summarize this pdf' }, + { + type: 'file', + fileData: 'JVBERi0xLjQK', + mimeType: 'application/pdf', + }, + ], + }, + ]); + }); + it('builds native Gemini requests from canonical envelopes', () => { const body = geminiGenerateContentTransformer.buildProtocolRequest({ operation: 'generate', diff --git a/src/server/transformers/shared/chatFormatsCore.ts b/src/server/transformers/shared/chatFormatsCore.ts index 6c2388a..e3f0401 100644 --- a/src/server/transformers/shared/chatFormatsCore.ts +++ b/src/server/transformers/shared/chatFormatsCore.ts @@ -338,6 +338,90 @@ function parseClaudeMessageContent(content: unknown): string { return extractTextAndReasoning(content).content; } +function buildOpenAiImageUrlBlock(url: string): Record { + return { + type: 'image_url', + image_url: { url }, + }; +} + +function buildOpenAiFileBlock(input: { + fileData?: string; + fileUrl?: string; + filename?: string; + mimeType?: string; +}): Record | null { + const fileData = typeof input.fileData === 'string' ? input.fileData.trim() : ''; + const fileUrl = typeof input.fileUrl === 'string' ? input.fileUrl.trim() : ''; + const filename = typeof input.filename === 'string' ? input.filename.trim() : ''; + const mimeType = typeof input.mimeType === 'string' ? input.mimeType.trim() : ''; + if (!fileData && !fileUrl) return null; + + const file: Record = {}; + if (fileData) file.file_data = fileData; + if (fileUrl && !fileData) file.file_url = fileUrl; + if (filename) file.filename = filename; + if (mimeType) file.mime_type = mimeType; + return { + type: 'file', + file, + }; +} + +function convertClaudeContentBlockToOpenAi(block: Record): Record | null { + const blockType = typeof block.type === 'string' ? block.type : ''; + + if (blockType === 'text') { + const text = parseClaudeMessageContent(block); + return text ? { type: 'text', text } : null; + } + + if (blockType === 'image') { + const source = isRecord(block.source) ? block.source : null; + const sourceType = typeof source?.type === 'string' ? source.type : ''; + if (sourceType === 'url' && typeof source?.url === 'string' && source.url.trim()) { + return buildOpenAiImageUrlBlock(source.url.trim()); + } + if ( + sourceType === 'base64' + && typeof source?.media_type === 'string' + && source.media_type.trim() + && typeof source?.data === 'string' + && source.data.trim() + ) { + return buildOpenAiImageUrlBlock(`data:${source.media_type.trim()};base64,${source.data.trim()}`); + } + return null; + } + + if (blockType === 'document') { + const source = isRecord(block.source) ? block.source : null; + const sourceType = typeof source?.type === 'string' ? source.type : ''; + return buildOpenAiFileBlock({ + fileData: sourceType === 'base64' && typeof source?.data === 'string' ? source.data : undefined, + fileUrl: sourceType === 'url' && typeof source?.url === 'string' ? source.url : undefined, + filename: typeof block.title === 'string' ? block.title : undefined, + mimeType: typeof source?.media_type === 'string' ? source.media_type : undefined, + }); + } + + const text = parseClaudeMessageContent(block); + return text ? { type: 'text', text } : null; +} + +function buildOpenAiMessageContent( + contentBlocks: Array>, +): string | Array> | undefined { + if (contentBlocks.length <= 0) return undefined; + if (contentBlocks.every((block) => block.type === 'text' && typeof block.text === 'string')) { + return contentBlocks + .map((block) => String(block.text).trim()) + .filter(Boolean) + .join('\n\n'); + } + return contentBlocks; +} + function parseResponsesOutputText(payload: Record): string { const direct = typeof payload.output_text === 'string' ? payload.output_text : ''; if (direct) return direct; @@ -695,7 +779,6 @@ function convertClaudeRequestToOpenAiBody(body: Record): { role: 'assistant', tool_calls: toolCalls, }; - // Keep textual assistant preface when present. assistantMessage.content = merged || ''; messages.push(assistantMessage); } else if (merged !== null) { diff --git a/src/server/transformers/shared/inputFile.test.ts b/src/server/transformers/shared/inputFile.test.ts index c9ddd53..f1e4d98 100644 --- a/src/server/transformers/shared/inputFile.test.ts +++ b/src/server/transformers/shared/inputFile.test.ts @@ -76,6 +76,21 @@ describe('shared input file helpers', () => { }); }); + it('creates anthropic document blocks from remote file urls', () => { + expect(toAnthropicDocumentBlock({ + fileUrl: 'https://example.com/remote.pdf', + filename: 'remote.pdf', + mimeType: 'application/pdf', + })).toEqual({ + type: 'document', + source: { + type: 'url', + url: 'https://example.com/remote.pdf', + }, + title: 'remote.pdf', + }); + }); + it('infers common mime types from filenames', () => { expect(inferInputFileMimeType({ filename: 'notes.md', mimeType: null })).toBe('text/markdown'); expect(inferInputFileMimeType({ filename: 'photo.jpeg', mimeType: null })).toBe('image/jpeg'); diff --git a/src/server/transformers/shared/inputFile.ts b/src/server/transformers/shared/inputFile.ts index 5ad6bd4..4fa2260 100644 --- a/src/server/transformers/shared/inputFile.ts +++ b/src/server/transformers/shared/inputFile.ts @@ -159,6 +159,16 @@ export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record | null { + if (file.fileUrl) { + return { + type: 'document', + source: { + type: 'url', + url: file.fileUrl, + }, + ...(file.filename ? { title: file.filename } : {}), + }; + } if (!file.fileData) return null; const parsedDataUrl = splitBase64DataUrl(file.fileData); const mimeType = parsedDataUrl?.mimeType || inferInputFileMimeType(file); diff --git a/src/web/api.ts b/src/web/api.ts index a2e1e0b..abccbef 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -156,6 +156,21 @@ export type ProxyTestJobResponse = { expiresAt?: string; }; +export type SystemProxyTestRequest = { + proxyUrl?: string; +}; + +export type SystemProxyTestResponse = { + success: true; + proxyUrl: string; + probeUrl: string; + finalUrl: string; + reachable: true; + ok: boolean; + statusCode: number; + latencyMs: number; +}; + export type ProxyLogStatusFilter = 'all' | 'success' | 'failed'; export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null; @@ -511,6 +526,11 @@ export const api = { method: 'PUT', body: JSON.stringify(data), }), + testSystemProxy: (data: SystemProxyTestRequest) => request('/api/settings/system-proxy/test', { + method: 'POST', + body: JSON.stringify(data), + timeoutMs: 20_000, + }), getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'), updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) => request('/api/settings/database/runtime', { diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 7bb9375..be27bce 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -1164,12 +1164,17 @@ export default function Accounts() { {isSub2ApiSelected && ( <> - setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))} - style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} - /> +
+ setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))} + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + /> +
+ 可在浏览器控制台执行 localStorage.getItem('refresh_token') 获取。 +
+
(null); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingRouting, setSavingRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); @@ -576,6 +583,31 @@ export default function Settings() { } }; + const testSystemProxy = async () => { + const proxyUrl = runtime.systemProxyUrl.trim(); + if (!proxyUrl) { + const message = '请先填写系统代理地址'; + setSystemProxyTestState({ kind: 'error', text: message }); + toast.info(message); + return; + } + + setTestingSystemProxy(true); + setSystemProxyTestState(null); + try { + const res = await api.testSystemProxy({ proxyUrl }); + const summary = `连通成功,延迟 ${res.latencyMs} ms`; + setSystemProxyTestState({ kind: 'success', text: summary }); + toast.success(`系统代理测试成功(${res.latencyMs} ms)`); + } catch (err: any) { + const message = err?.message || '系统代理测试失败'; + setSystemProxyTestState({ kind: 'error', text: message }); + toast.error(message); + } finally { + setTestingSystemProxy(false); + } + }; + const saveProxyFailureRules = async () => { setSavingProxyFailureRules(true); try { @@ -1114,13 +1146,39 @@ export default function Settings() {
setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value }))} + onChange={(e) => { + setRuntime((prev) => ({ ...prev, systemProxyUrl: e.target.value })); + setSystemProxyTestState(null); + }} placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)" style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }} /> - +
+ + +
+ {systemProxyTestState && ( +
+ {systemProxyTestState.text} +
+ )}
diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 125d789..d4b0968 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -40,6 +40,7 @@ type SiteRow = { externalCheckinUrl?: string | null; platform?: string; status?: string; + proxyUrl?: string | null; useSystemProxy?: boolean; customHeaders?: string | null; globalWeight?: number; @@ -382,6 +383,7 @@ export default function Sites() { url: form.url.trim(), externalCheckinUrl: form.externalCheckinUrl.trim(), platform: form.platform.trim(), + proxyUrl: form.proxyUrl.trim(), useSystemProxy: !!form.useSystemProxy, customHeaders: serializedCustomHeaders.customHeaders, globalWeight: Number(parsedGlobalWeight.toFixed(3)), @@ -918,6 +920,15 @@ export default function Sites() {
)} + setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} + style={formInputStyle} + /> +
+ 填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。 +