Merge origin/main into codex/modeltester-attachment-review-fixes

This commit is contained in:
cita
2026-03-20 23:09:27 +08:00
46 changed files with 1633 additions and 139 deletions
+2
View File
@@ -182,6 +182,8 @@ function ensureTokenManagementSchema() {
WHERE WHERE
a.api_token IS NOT NULL a.api_token IS NOT NULL
AND trim(a.api_token) <> '' AND trim(a.api_token) <> ''
AND a.access_token IS NOT NULL
AND trim(a.access_token) <> ''
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM account_tokens AS t SELECT 1 FROM account_tokens AS t
WHERE t.account_id = a.id WHERE t.account_id = a.id
+2
View File
@@ -22,6 +22,7 @@ import { oauthRoutes } from './routes/api/oauth.js';
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
import { proxyRoutes } from './routes/proxy/router.js'; import { proxyRoutes } from './routes/proxy/router.js';
import { startScheduler } from './services/checkinScheduler.js'; import { startScheduler } from './services/checkinScheduler.js';
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
import { buildStartupSummaryLines } from './services/startupInfo.js'; import { buildStartupSummaryLines } from './services/startupInfo.js';
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
@@ -329,6 +330,7 @@ try {
await repairStoredCreatedAtValues(); await repairStoredCreatedAtValues();
await migrateSiteApiKeysToAccounts(); await migrateSiteApiKeysToAccounts();
await ensureDefaultSitesSeeded(); await ensureDefaultSitesSeeded();
await rebuildTokenRoutesFromAvailability();
console.log('Loaded runtime settings overrides'); console.log('Loaded runtime settings overrides');
} catch (error) { } catch (error) {
@@ -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({ expect(resolveConversationFileEndpointCapability({
sitePlatform: 'claude', sitePlatform: 'claude',
endpoint: 'messages', endpoint: 'messages',
})).toMatchObject({ })).toMatchObject({
image: 'native', image: 'native',
audio: 'unsupported', audio: 'unsupported',
document: 'inline_only', document: 'native',
preservesRemoteDocumentUrl: false, preservesRemoteDocumentUrl: true,
}); });
expect(resolveConversationFileEndpointCapability({ expect(resolveConversationFileEndpointCapability({
@@ -151,8 +151,8 @@ export function resolveConversationFileEndpointCapability(input: {
return { return {
image: 'native', image: 'native',
audio: 'unsupported', audio: 'unsupported',
document: 'inline_only', document: 'native',
preservesRemoteDocumentUrl: false, preservesRemoteDocumentUrl: true,
}; };
} }
return { return {
@@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.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 { type DownstreamFormat } from '../../transformers/shared/normalized.js';
import { import {
buildClaudeCountTokensUpstreamRequest, buildClaudeCountTokensUpstreamRequest,
@@ -24,6 +24,7 @@ import {
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
@@ -164,6 +165,7 @@ export async function handleChatSurfaceRequest(
requestedModelHint: requestedModel, requestedModelHint: requestedModel,
requestCapabilities: { requestCapabilities: {
hasNonImageFileInput, hasNonImageFileInput,
conversationFileSummary,
}, },
}; };
const buildProviderHeaders = () => ( const buildProviderHeaders = () => (
@@ -241,7 +243,7 @@ export async function handleChatSurfaceRequest(
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
}; };
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); 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); const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
if (refreshedResponse.ok) { if (refreshedResponse.ok) {
return { return {
@@ -263,7 +265,6 @@ export async function handleChatSurfaceRequest(
try { try {
const endpointResult = await executeEndpointFlow({ const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url, siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates, endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint), buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest, dispatchRequest,
@@ -33,6 +33,7 @@ import {
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js'; import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js'; import { insertProxyLog } from '../../services/proxyLogStore.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
const GEMINI_MODEL_PROBES = [ const GEMINI_MODEL_PROBES = [
@@ -685,6 +686,8 @@ export async function geminiProxyRoute(app: FastifyInstance) {
modelName: actualModel, modelName: actualModel,
stream: isStreamAction, stream: isStreamAction,
}); });
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const endpointCandidates = await resolveUpstreamEndpointCandidates( const endpointCandidates = await resolveUpstreamEndpointCandidates(
{ {
site: selected.site, site: selected.site,
@@ -693,12 +696,20 @@ export async function geminiProxyRoute(app: FastifyInstance) {
actualModel, actualModel,
'openai', 'openai',
requestedModel, requestedModel,
{
hasNonImageFileInput,
conversationFileSummary,
},
); );
const endpointRuntimeContext = { const endpointRuntimeContext = {
siteId: selected.site.id, siteId: selected.site.id,
modelName: actualModel, modelName: actualModel,
downstreamFormat: 'openai' as const, downstreamFormat: 'openai' as const,
requestedModelHint: requestedModel, requestedModelHint: requestedModel,
requestCapabilities: {
hasNonImageFileInput,
conversationFileSummary,
},
}; };
const buildEndpointRequest = ( const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses', endpoint: 'chat' | 'messages' | 'responses',
@@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.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 { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { import {
buildUpstreamEndpointRequest, buildUpstreamEndpointRequest,
@@ -19,6 +19,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js'; import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js'; import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js'; import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js'; import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
@@ -237,14 +238,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
const responsesConversationFileSummary = summarizeConversationFileInputsInResponsesBody(normalizedResponsesBody); const responsesConversationFileSummary = summarizeConversationFileInputsInResponsesBody(normalizedResponsesBody);
const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl
|| carriesResponsesFileUrlInput(normalizedResponsesBody.input); || 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( const endpointCandidates = await resolveUpstreamEndpointCandidates(
{ {
site: selected.site, site: selected.site,
@@ -259,9 +252,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
}, },
); );
if (requiresNativeResponsesFileUrl) {
endpointCandidates.splice(0, endpointCandidates.length, 'responses');
}
if (endpointCandidates.length === 0) { if (endpointCandidates.length === 0) {
endpointCandidates.push('responses', 'chat', 'messages'); endpointCandidates.push('responses', 'chat', 'messages');
} }
@@ -272,6 +262,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
requestedModelHint: requestedModel, requestedModelHint: requestedModel,
requestCapabilities: { requestCapabilities: {
hasNonImageFileInput, hasNonImageFileInput,
conversationFileSummary,
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning, wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
}, },
}; };
@@ -341,7 +332,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
}; };
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); 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); const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
if (refreshedResponse.ok) { if (refreshedResponse.ok) {
return { return {
@@ -364,7 +355,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
try { try {
const endpointResult = await executeEndpointFlow({ const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url, siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates, endpointCandidates,
buildRequest: (endpoint) => buildEndpointRequest(endpoint), buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest, dispatchRequest,
@@ -22,6 +22,10 @@ function validateKeyShape(key: string): boolean {
function looksLikeUniqueViolation(error: unknown): boolean { function looksLikeUniqueViolation(error: unknown): boolean {
const message = (error as Error | undefined)?.message || ''; 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'); return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
} }
@@ -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<typeof import('undici')>('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<string, unknown>];
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');
});
});
+133 -1
View File
@@ -1,5 +1,6 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import cron from 'node-cron'; import cron from 'node-cron';
import { fetch } from 'undici';
import { config } from '../../config.js'; import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js'; import { upsertSetting } from '../../db/upsertSetting.js';
@@ -17,7 +18,7 @@ import {
} from '../../services/databaseMigrationService.js'; } from '../../services/databaseMigrationService.js';
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
import { extractClientIp, isIpAllowed } from '../../middleware/auth.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 { performFactoryReset } from '../../services/factoryResetService.js';
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js'; import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js'; import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
@@ -70,6 +71,10 @@ interface DatabaseMigrationBody {
ssl?: unknown; ssl?: unknown;
} }
interface SystemProxyTestBody {
proxyUrl?: unknown;
}
type RuntimeDatabaseConfig = { type RuntimeDatabaseConfig = {
dialect: MigrationDialect; dialect: MigrationDialect;
connectionString: string; connectionString: string;
@@ -80,6 +85,8 @@ const PROXY_TOKEN_PREFIX = 'sk-';
const DB_TYPE_SETTING_KEY = 'db_type'; const DB_TYPE_SETTING_KEY = 'db_type';
const DB_URL_SETTING_KEY = 'db_url'; const DB_URL_SETTING_KEY = 'db_url';
const DB_SSL_SETTING_KEY = 'db_ssl'; 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 { function isValidProxyToken(value: string): boolean {
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6; return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
@@ -118,6 +125,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) {
return n; return n;
} }
function extractNestedErrorMessages(error: unknown): string[] {
const messages: string[] = [];
const visited = new Set<unknown>();
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[] { function toStringList(value: unknown): string[] {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
@@ -524,6 +619,43 @@ export async function settingsRoutes(app: FastifyInstance) {
return getRuntimeSettingsResponse(currentAdminIp); 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) => { app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
const body = request.body || {}; const body = request.body || {};
const changedLabels: string[] = []; const changedLabels: string[] = [];
+26 -4
View File
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
type DbModule = typeof import('../../db/index.js'); type DbModule = typeof import('../../db/index.js');
describe('sites system proxy settings', () => { describe('sites proxy settings', () => {
let app: FastifyInstance; let app: FastifyInstance;
let db: DbModule['db']; let db: DbModule['db'];
let schema: DbModule['schema']; let schema: DbModule['schema'];
@@ -36,7 +36,7 @@ describe('sites system proxy settings', () => {
delete process.env.DATA_DIR; 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({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/api/sites', url: '/api/sites',
@@ -44,6 +44,7 @@ describe('sites system proxy settings', () => {
name: 'proxy-site', name: 'proxy-site',
url: 'https://proxy-site.example.com', url: 'https://proxy-site.example.com',
platform: 'new-api', platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
useSystemProxy: true, useSystemProxy: true,
customHeaders: JSON.stringify({ customHeaders: JSON.stringify({
'cf-access-client-id': 'site-client-id', 'cf-access-client-id': 'site-client-id',
@@ -56,11 +57,13 @@ describe('sites system proxy settings', () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const payload = response.json() as { const payload = response.json() as {
proxyUrl?: string | null;
useSystemProxy?: boolean; useSystemProxy?: boolean;
customHeaders?: string | null; customHeaders?: string | null;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
globalWeight?: number; globalWeight?: number;
}; };
expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080');
expect(payload.useSystemProxy).toBe(true); expect(payload.useSystemProxy).toBe(true);
expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}'); expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}');
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare'); 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'); 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 () => { it('rejects invalid site global weight', async () => {
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
@@ -115,7 +134,7 @@ describe('sites system proxy settings', () => {
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl'); 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({ const created = await app.inject({
method: 'POST', method: 'POST',
url: '/api/sites', url: '/api/sites',
@@ -133,12 +152,15 @@ describe('sites system proxy settings', () => {
method: 'PUT', method: 'PUT',
url: `/api/sites/${site.id}`, url: `/api/sites/${site.id}`,
payload: { payload: {
proxyUrl: 'http://127.0.0.1:8080',
useSystemProxy: true, useSystemProxy: true,
}, },
}); });
expect(response.statusCode).toBe(200); 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 () => { it('rejects invalid custom headers json', async () => {
+14 -2
View File
@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js'; import { db, schema } from '../../db/index.js';
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js'; 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 { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js'; import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js'; import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
@@ -222,6 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name: string; name: string;
url: string; url: string;
platform?: string; platform?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean; useSystemProxy?: boolean;
customHeaders?: string | null; customHeaders?: string | null;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
@@ -230,7 +231,7 @@ export async function sitesRoutes(app: FastifyInstance) {
sortOrder?: number; sortOrder?: number;
globalWeight?: number; globalWeight?: number;
} }>('/api/sites', async (request, reply) => { } }>('/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); const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) { if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); 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) { if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' }); 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); const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) { if (!normalizedExternalCheckinUrl.valid) {
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' }); 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, name,
url: url.replace(/\/+$/, ''), url: url.replace(/\/+$/, ''),
platform: detectedPlatform, platform: detectedPlatform,
proxyUrl: normalizedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false, useSystemProxy: normalizedUseSystemProxy ?? false,
customHeaders: normalizedCustomHeaders.customHeaders, customHeaders: normalizedCustomHeaders.customHeaders,
externalCheckinUrl: normalizedExternalCheckinUrl.url, externalCheckinUrl: normalizedExternalCheckinUrl.url,
@@ -300,6 +306,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name?: string; name?: string;
url?: string; url?: string;
platform?: string; platform?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean; useSystemProxy?: boolean;
customHeaders?: string | null; customHeaders?: string | null;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
@@ -328,6 +335,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) { if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' }); 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); const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) { if (!normalizedExternalCheckinUrl.valid) {
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' }); 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.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform; if (body.platform !== undefined) updates.platform = body.platform;
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders; if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url; if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
@@ -14,6 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
estimatedCostFromQuota: 0, estimatedCostFromQuota: 0,
recoveredFromSelfLog: false, recoveredFromSelfLog: false,
})); }));
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const dbInsertMock = vi.fn((_arg?: any) => ({ const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({ values: () => ({
run: () => undefined, run: () => undefined,
@@ -60,6 +61,10 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
})); }));
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
}));
vi.mock('../../db/index.js', () => ({ vi.mock('../../db/index.js', () => ({
db: { db: {
insert: (arg: any) => dbInsertMock(arg), insert: (arg: any) => dbInsertMock(arg),
@@ -107,6 +112,7 @@ describe('chat proxy codex oauth compatibility', () => {
reportProxyAllFailedMock.mockReset(); reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset(); reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear(); resolveProxyUsageWithSelfLogFallbackMock.mockClear();
refreshOauthAccessTokenSingleflightMock.mockReset();
dbInsertMock.mockClear(); dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
@@ -130,6 +136,11 @@ describe('chat proxy codex oauth compatibility', () => {
actualModel: 'gpt-5.4', actualModel: 'gpt-5.4',
}); });
selectNextChannelMock.mockReturnValue(null); selectNextChannelMock.mockReturnValue(null);
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
accessToken: 'fresh-access-token',
accountId: 33,
accountKey: 'chatgpt-account-123',
});
}); });
afterAll(async () => { 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('event: response.created');
expect(response.body).not.toContain('"type":"response.output_text.delta"'); 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');
});
}); });
+207 -7
View File
@@ -2032,6 +2032,87 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/messages'); 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 () => { it('prefers native /v1/responses for claude-family /v1/responses requests that explicitly ask for encrypted reasoning', async () => {
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 }, 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({ selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 }, channel: { id: 11, routeId: 22 },
site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' }, site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' },
@@ -2218,6 +2299,18 @@ describe('chat proxy stream behavior', () => {
tokenValue: 'sk-demo', tokenValue: 'sk-demo',
actualModel: 'upstream-claude', 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({ const response = await app.inject({
method: 'POST', method: 'POST',
@@ -2241,12 +2334,119 @@ describe('chat proxy stream behavior', () => {
}, },
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(200);
const body = response.json(); expect(fetchMock).toHaveBeenCalledTimes(1);
expect(body?.error?.type).toBe('invalid_request_error'); const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(body?.error?.message).toContain('input_file.file_url'); expect(targetUrl).toContain('/v1/messages');
expect(body?.error?.message).toContain('/v1/responses'); const forwardedBody = JSON.parse(options.body);
expect(fetchMock).not.toHaveBeenCalled(); 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 () => { it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => {
+1 -16
View File
@@ -73,6 +73,7 @@ describe('executeEndpointFlow', () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(dispatchRequest).toHaveBeenCalledTimes(1); expect(dispatchRequest).toHaveBeenCalledTimes(1);
expect(dispatchRequest.mock.calls[0]?.[1]).toBe('https://example.com/v1/responses');
expect(fetchMock).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled();
}); });
@@ -219,22 +220,6 @@ describe('executeEndpointFlow', () => {
expect(fetchMock).toHaveBeenCalledTimes(1); 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 () => { it('returns normalized final error when all endpoints fail', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' }, error: { message: 'upstream_error', type: 'upstream_error' },
+1 -11
View File
@@ -59,7 +59,6 @@ export function withUpstreamPath(path: string, message: string): string {
type ExecuteEndpointFlowInput = { type ExecuteEndpointFlowInput = {
siteUrl: string; siteUrl: string;
proxyUrl?: string | null;
endpointCandidates: UpstreamEndpoint[]; endpointCandidates: UpstreamEndpoint[];
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest; buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
dispatchRequest?: ( dispatchRequest?: (
@@ -73,12 +72,6 @@ type ExecuteEndpointFlowInput = {
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>; onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
}; };
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<EndpointFlowResult> { export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
const endpointCount = input.endpointCandidates.length; const endpointCount = input.endpointCandidates.length;
if (endpointCount <= 0) { if (endpointCount <= 0) {
@@ -96,10 +89,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) { for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint; const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
const request = input.buildRequest(endpoint, endpointIndex); const request = input.buildRequest(endpoint, endpointIndex);
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path); const targetUrl = buildUpstreamUrl(input.siteUrl, request.path);
const targetUrl = input.proxyUrl
? buildAbsoluteUrl(input.proxyUrl, request.path)
: defaultTarget;
let response = input.dispatchRequest let response = input.dispatchRequest
? await input.dispatchRequest(request, targetUrl) ? await input.dispatchRequest(request, targetUrl)
+86
View File
@@ -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 () => { it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => {
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
channel: { id: 43, routeId: 22 }, channel: { id: 43, routeId: 22 },
@@ -214,6 +214,69 @@ describe('responses proxy codex oauth refresh', () => {
expect(response.json()?.output_text).toContain('ok after codex token 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 () => { it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_no_system', id: 'resp_codex_no_system',
@@ -216,6 +216,87 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(order).toEqual(['responses', 'chat', 'messages']); 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 () => { it('learns a better endpoint from explicit upstream protocol errors', async () => {
recordUpstreamEndpointFailure({ recordUpstreamEndpointFailure({
siteId: baseContext.site.id, siteId: baseContext.site.id,
+31 -2
View File
@@ -42,6 +42,7 @@ export type EndpointPreference = DownstreamFormat | 'responses';
type EndpointCapabilityProfile = { type EndpointCapabilityProfile = {
preferMessagesForClaudeModel: boolean; preferMessagesForClaudeModel: boolean;
hasNonImageFileInput: boolean; hasNonImageFileInput: boolean;
hasRemoteDocumentUrl: boolean;
wantsNativeResponsesReasoning: boolean; wantsNativeResponsesReasoning: boolean;
}; };
@@ -619,6 +620,9 @@ function buildEndpointCapabilityProfile(input?: {
input?.requestCapabilities?.conversationFileSummary?.hasDocument === true input?.requestCapabilities?.conversationFileSummary?.hasDocument === true
|| input?.requestCapabilities?.hasNonImageFileInput === true || input?.requestCapabilities?.hasNonImageFileInput === true
), ),
hasRemoteDocumentUrl: (
input?.requestCapabilities?.conversationFileSummary?.hasRemoteDocumentUrl === true
),
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true, wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
}; };
} }
@@ -634,6 +638,7 @@ function buildEndpointRuntimeStateKey(input: {
input.downstreamFormat, input.downstreamFormat,
capabilityProfile.preferMessagesForClaudeModel ? 'claude' : 'generic', capabilityProfile.preferMessagesForClaudeModel ? 'claude' : 'generic',
capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles', capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles',
capabilityProfile.hasRemoteDocumentUrl ? 'remoteurl' : 'noremoteurl',
capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning', capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning',
].join(':'); ].join(':');
} }
@@ -714,15 +719,37 @@ function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEnd
function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean { function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean {
if (isEndpointDispatchDeniedError(status, errorText)) return true; 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(); const text = (errorText || '').toLowerCase();
return ( 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('please use /v1/')
|| text.includes('does not allow /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 { export function resetUpstreamEndpointRuntimeState(): void {
endpointRuntimeStates.clear(); endpointRuntimeStates.clear();
} }
@@ -739,6 +766,8 @@ export function recordUpstreamEndpointSuccess(input: {
wantsNativeResponsesReasoning?: boolean; wantsNativeResponsesReasoning?: boolean;
}; };
}): void { }): void {
if (!shouldRememberSuccessfulEndpoint(input)) return;
const nowMs = Date.now(); const nowMs = Date.now();
const key = buildEndpointRuntimeStateKey({ const key = buildEndpointRuntimeStateKey({
siteId: input.siteId, siteId: input.siteId,
+18 -6
View File
@@ -90,13 +90,25 @@ function normalizePositiveIntegerOrNull(value: unknown): number | null {
return normalized; return normalized;
} }
function parseJson(value: string | null | undefined): unknown { function parseJson(value: unknown): unknown {
if (!value) return null; if (value === null || value === undefined) return null;
try {
return JSON.parse(value); // PostgreSQL JSONB columns are returned as parsed objects/arrays by the pg driver
} catch { if (typeof value === 'object') {
return null; 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 { export function normalizeGroupNameInput(input: unknown): string | null {
@@ -248,6 +248,55 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); 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 () => { it('returns structured result when account missing', async () => {
const result = await refreshModelsForAccount(9999); const result = await refreshModelsForAccount(9999);
+61
View File
@@ -85,6 +85,67 @@ describe('rebuildTokenRoutesFromAvailability', () => {
expect(channels[0]?.manualOverride).toBe(false); 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 () => { it('creates an exact route with an account-direct channel for oauth model availability', async () => {
const site = await db.insert(schema.sites).values({ const site = await db.insert(schema.sites).values({
name: 'codex-site', name: 'codex-site',
+17 -17
View File
@@ -10,8 +10,8 @@ import {
isUsableAccountToken, isUsableAccountToken,
} from './accountTokenService.js'; } from './accountTokenService.js';
import { import {
getCredentialModeFromExtraConfig,
mergeAccountExtraConfig, mergeAccountExtraConfig,
requiresManagedAccountTokens,
resolvePlatformUserId, resolvePlatformUserId,
supportsDirectAccountRoutingConnection, supportsDirectAccountRoutingConnection,
} from './accountExtraConfig.js'; } from './accountExtraConfig.js';
@@ -141,12 +141,6 @@ function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled'; 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[] { function normalizeModels(models: string[]): string[] {
const normalizedModels: string[] = []; const normalizedModels: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -821,18 +815,21 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
} catch { } } catch { }
} }
let enabledTokens = await db.select() const usesManagedTokens = requiresManagedAccountTokens(account);
.from(schema.accountTokens) let enabledTokens = usesManagedTokens
.where(and( ? await db.select()
eq(schema.accountTokens.accountId, account.id), .from(schema.accountTokens)
eq(schema.accountTokens.enabled, true), .where(and(
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accountTokens.accountId, account.id),
)) eq(schema.accountTokens.enabled, true),
.all(); eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.all()
: [];
enabledTokens = enabledTokens.filter(isUsableAccountToken); enabledTokens = enabledTokens.filter(isUsableAccountToken);
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table. // Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) { if (usesManagedTokens && enabledTokens.length === 0) {
const fallback = discoveredApiToken || account.apiToken || null; const fallback = discoveredApiToken || account.apiToken || null;
if (fallback) { if (fallback) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' }); ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
@@ -1018,7 +1015,10 @@ export async function rebuildTokenRoutesFromAvailability() {
), ),
) )
.all(); .all();
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens)); const usableTokenRows = tokenRows.filter((row) => (
isUsableAccountToken(row.account_tokens)
&& requiresManagedAccountTokens(row.accounts)
));
const accountRows = await db.select().from(schema.modelAvailability) const accountRows = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
+32
View File
@@ -57,6 +57,22 @@ describe('siteProxy', () => {
.toBeNull(); .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 () => { it('injects dispatcher when a site opts into the configured system proxy', async () => {
await db.insert(schema.settings).values({ await db.insert(schema.settings).values({
key: 'system_proxy_url', key: 'system_proxy_url',
@@ -75,6 +91,20 @@ describe('siteProxy', () => {
expect('dispatcher' in requestInit).toBe(true); 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 () => { it('injects a working dispatcher for socks5 system proxies', async () => {
const upstreamServer = createServer((_request, response) => { const upstreamServer = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' }); 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 () => { it('merges site custom headers from site records even without cache lookup', async () => {
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js'); const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
const requestInit = withSiteRecordProxyRequestInit({ const requestInit = withSiteRecordProxyRequestInit({
proxyUrl: 'http://127.0.0.1:7890',
useSystemProxy: false, useSystemProxy: false,
customHeaders: JSON.stringify({ customHeaders: JSON.stringify({
'x-site-scope': 'site-level', 'x-site-scope': 'site-level',
@@ -184,5 +215,6 @@ describe('siteProxy', () => {
expect(headers.get('x-site-scope')).toBe('site-level'); expect(headers.get('x-site-scope')).toBe('site-level');
expect(headers.get('x-request-id')).toBe('req-1'); expect(headers.get('x-request-id')).toBe('req-1');
expect('dispatcher' in requestInit).toBe(true);
}); });
}); });
+8 -1
View File
@@ -31,6 +31,7 @@ const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
type SiteProxyRow = { type SiteProxyRow = {
siteUrl: string; siteUrl: string;
proxyUrl: string | null;
useSystemProxy: boolean; useSystemProxy: boolean;
customHeaders: string | null; customHeaders: string | null;
}; };
@@ -42,6 +43,7 @@ type ParsedSiteProxyInput = {
}; };
type SiteProxyConfigLike = { type SiteProxyConfigLike = {
proxyUrl?: string | null;
useSystemProxy?: boolean | null; useSystemProxy?: boolean | null;
customHeaders?: string | null; customHeaders?: string | null;
}; };
@@ -103,6 +105,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
db db
.select({ .select({
siteUrl: schema.sites.url, siteUrl: schema.sites.url,
proxyUrl: schema.sites.proxyUrl,
useSystemProxy: schema.sites.useSystemProxy, useSystemProxy: schema.sites.useSystemProxy,
customHeaders: schema.sites.customHeaders, customHeaders: schema.sites.customHeaders,
}) })
@@ -129,6 +132,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
loadedAt: nowMs, loadedAt: nowMs,
rows: rows.map((row) => ({ rows: rows.map((row) => ({
siteUrl: normalizeSiteUrl(row.siteUrl), siteUrl: normalizeSiteUrl(row.siteUrl),
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
useSystemProxy: !!row.useSystemProxy, useSystemProxy: !!row.useSystemProxy,
customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null, customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null,
})), })),
@@ -368,7 +372,8 @@ async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise
const rows = await getCachedSiteProxyRows(); const rows = await getCachedSiteProxyRows();
const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl); const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl);
const proxyUrl = matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null; const proxyUrl = matchedRow?.proxyUrl
|| (matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null);
return { return {
proxyUrl: proxyUrl || null, proxyUrl: proxyUrl || null,
customHeaders: matchedRow?.customHeaders ?? null, customHeaders: matchedRow?.customHeaders ?? null,
@@ -425,6 +430,8 @@ export function withExplicitProxyRequestInit(
} }
export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null { export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null {
const explicitProxyUrl = normalizeSiteProxyUrl(site?.proxyUrl);
if (explicitProxyUrl) return explicitProxyUrl;
if (!site?.useSystemProxy) return null; if (!site?.useSystemProxy) return null;
return normalizeSiteProxyUrl(config.systemProxyUrl); return normalizeSiteProxyUrl(config.systemProxyUrl);
} }
@@ -12,6 +12,10 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function isImageMimeType(mimeType: string): boolean {
return mimeType.toLowerCase().startsWith('image/');
}
function parseJsonIfPossible(value: string): unknown { function parseJsonIfPossible(value: string): unknown {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return {}; if (!trimmed) return {};
@@ -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', () => { it('builds native Gemini requests from canonical envelopes', () => {
const body = geminiGenerateContentTransformer.buildProtocolRequest({ const body = geminiGenerateContentTransformer.buildProtocolRequest({
operation: 'generate', operation: 'generate',
@@ -338,6 +338,90 @@ function parseClaudeMessageContent(content: unknown): string {
return extractTextAndReasoning(content).content; return extractTextAndReasoning(content).content;
} }
function buildOpenAiImageUrlBlock(url: string): Record<string, unknown> {
return {
type: 'image_url',
image_url: { url },
};
}
function buildOpenAiFileBlock(input: {
fileData?: string;
fileUrl?: string;
filename?: string;
mimeType?: string;
}): Record<string, unknown> | 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<string, unknown> = {};
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<string, unknown>): Record<string, unknown> | 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<Record<string, unknown>>,
): string | Array<Record<string, unknown>> | 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, unknown>): string { function parseResponsesOutputText(payload: Record<string, unknown>): string {
const direct = typeof payload.output_text === 'string' ? payload.output_text : ''; const direct = typeof payload.output_text === 'string' ? payload.output_text : '';
if (direct) return direct; if (direct) return direct;
@@ -695,7 +779,6 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
role: 'assistant', role: 'assistant',
tool_calls: toolCalls, tool_calls: toolCalls,
}; };
// Keep textual assistant preface when present.
assistantMessage.content = merged || ''; assistantMessage.content = merged || '';
messages.push(assistantMessage); messages.push(assistantMessage);
} else if (merged !== null) { } else if (merged !== null) {
@@ -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', () => { it('infers common mime types from filenames', () => {
expect(inferInputFileMimeType({ filename: 'notes.md', mimeType: null })).toBe('text/markdown'); expect(inferInputFileMimeType({ filename: 'notes.md', mimeType: null })).toBe('text/markdown');
expect(inferInputFileMimeType({ filename: 'photo.jpeg', mimeType: null })).toBe('image/jpeg'); expect(inferInputFileMimeType({ filename: 'photo.jpeg', mimeType: null })).toBe('image/jpeg');
@@ -159,6 +159,16 @@ export function toOpenAiChatFileBlock(file: NormalizedInputFile): Record<string,
} }
export function toAnthropicDocumentBlock(file: NormalizedInputFile): Record<string, unknown> | null { export function toAnthropicDocumentBlock(file: NormalizedInputFile): Record<string, unknown> | null {
if (file.fileUrl) {
return {
type: 'document',
source: {
type: 'url',
url: file.fileUrl,
},
...(file.filename ? { title: file.filename } : {}),
};
}
if (!file.fileData) return null; if (!file.fileData) return null;
const parsedDataUrl = splitBase64DataUrl(file.fileData); const parsedDataUrl = splitBase64DataUrl(file.fileData);
const mimeType = parsedDataUrl?.mimeType || inferInputFileMimeType(file); const mimeType = parsedDataUrl?.mimeType || inferInputFileMimeType(file);
+20
View File
@@ -156,6 +156,21 @@ export type ProxyTestJobResponse = {
expiresAt?: string; 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 ProxyLogStatusFilter = 'all' | 'success' | 'failed';
export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null; export type ProxyLogClientConfidence = 'exact' | 'heuristic' | 'unknown' | null;
@@ -511,6 +526,11 @@ export const api = {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), 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'), getRuntimeDatabaseConfig: () => request('/api/settings/database/runtime'),
updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) => updateRuntimeDatabaseConfig: (data: { dialect: 'sqlite' | 'mysql' | 'postgres'; connectionString: string; ssl?: boolean }) =>
request('/api/settings/database/runtime', { request('/api/settings/database/runtime', {
+11 -6
View File
@@ -1164,12 +1164,17 @@ export default function Accounts() {
</div> </div>
{isSub2ApiSelected && ( {isSub2ApiSelected && (
<> <>
<input <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
placeholder="Sub2API refresh_token(可选,用于托管自动续期)" <input
value={tokenForm.refreshToken} placeholder="Sub2API refresh_token(可选,用于托管自动续期)"
onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))} value={tokenForm.refreshToken}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} onChange={(e) => setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))}
/> style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
可在浏览器控制台执行 <code style={{ fontFamily: 'var(--font-mono)' }}>localStorage.getItem('refresh_token')</code> 获取。
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<input <input
placeholder="token_expires_at(可选,毫秒时间戳)" placeholder="token_expires_at(可选,毫秒时间戳)"
+62 -4
View File
@@ -55,6 +55,11 @@ type RuntimeSettings = {
currentAdminIp?: string; currentAdminIp?: string;
}; };
type SystemProxyTestState =
| { kind: 'success'; text: string }
| { kind: 'error'; text: string }
| null;
type DownstreamApiKeyItem = { type DownstreamApiKeyItem = {
id: number; id: number;
name: string; name: string;
@@ -222,6 +227,8 @@ export default function Settings() {
const [testingCheckin, setTestingCheckin] = useState(false); const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false); const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false); const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false); const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [savingRouting, setSavingRouting] = useState(false); const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = 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 () => { const saveProxyFailureRules = async () => {
setSavingProxyFailureRules(true); setSavingProxyFailureRules(true);
try { try {
@@ -1114,13 +1146,39 @@ export default function Settings() {
</div> </div>
<input <input
value={runtime.systemProxyUrl} value={runtime.systemProxyUrl}
onChange={(e) => 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)" placeholder="系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }} style={{ ...inputStyle, fontFamily: 'var(--font-mono)', marginBottom: 10 }}
/> />
<button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary"> <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
{savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'} <button onClick={saveSystemProxy} disabled={savingSystemProxy} className="btn btn-primary">
</button> {savingSystemProxy ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存系统代理'}
</button>
<button
onClick={testSystemProxy}
disabled={testingSystemProxy}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{testingSystemProxy ? <><span className="spinner spinner-sm" /> 测试中...</> : '测试系统代理'}
</button>
</div>
{systemProxyTestState && (
<div
style={{
fontSize: 12,
marginTop: 10,
color: systemProxyTestState.kind === 'success'
? 'var(--color-success)'
: 'var(--color-danger)',
}}
>
{systemProxyTestState.text}
</div>
)}
</div> </div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
+11
View File
@@ -40,6 +40,7 @@ type SiteRow = {
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
platform?: string; platform?: string;
status?: string; status?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean; useSystemProxy?: boolean;
customHeaders?: string | null; customHeaders?: string | null;
globalWeight?: number; globalWeight?: number;
@@ -382,6 +383,7 @@ export default function Sites() {
url: form.url.trim(), url: form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(), externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(), platform: form.platform.trim(),
proxyUrl: form.proxyUrl.trim(),
useSystemProxy: !!form.useSystemProxy, useSystemProxy: !!form.useSystemProxy,
customHeaders: serializedCustomHeaders.customHeaders, customHeaders: serializedCustomHeaders.customHeaders,
globalWeight: Number(parsedGlobalWeight.toFixed(3)), globalWeight: Number(parsedGlobalWeight.toFixed(3)),
@@ -918,6 +920,15 @@ export default function Sites() {
</div> </div>
)} )}
</div> </div>
<input
placeholder="站点代理(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
value={form.proxyUrl}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
style={formInputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
填写后优先使用站点代理;留空则使用系统代理或直连(取决于设置开关状态)。
</div>
<label style={{ <label style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -31,14 +31,14 @@ describe('resolveConversationFileCapability', () => {
imageMode: 'native', imageMode: 'native',
audioMode: 'unsupported', audioMode: 'unsupported',
documentMode: 'inline_only', documentMode: 'inline_only',
reason: '', reason: '当前界面的会话附件会以内联文档方式发送。',
}); });
expect(resolveConversationFileCapability('gemini')).toEqual({ expect(resolveConversationFileCapability('gemini')).toEqual({
supported: true, supported: true,
imageMode: 'native', imageMode: 'native',
audioMode: 'native', audioMode: 'native',
documentMode: 'inline_only', documentMode: 'inline_only',
reason: '', reason: '当前界面的会话附件会以内联文档方式发送。',
}); });
}); });
@@ -87,7 +87,7 @@ export function resolveConversationFileCapability(
imageMode: 'native', imageMode: 'native',
audioMode: 'unsupported', audioMode: 'unsupported',
documentMode: 'inline_only', documentMode: 'inline_only',
reason: '', reason: '当前界面的会话附件会以内联文档方式发送。',
}; };
} }
@@ -97,7 +97,7 @@ export function resolveConversationFileCapability(
imageMode: 'native', imageMode: 'native',
audioMode: 'native', audioMode: 'native',
documentMode: 'inline_only', documentMode: 'inline_only',
reason: '', reason: '当前界面的会话附件会以内联文档方式发送。',
}; };
} }
@@ -223,30 +223,52 @@ describe('modelTesterSession', () => {
]); ]);
}); });
it('extracts both uploaded references and inline attachments from user messages', () => { it('extracts inline conversation file data for retry flows', () => {
const message = createConversationUserMessage('请重新发送附件', [ const files = extractConversationUploadedFilesFromMessage({
id: 'u1',
role: 'user',
content: '请总结附件',
createAt: 1,
parts: [
{
type: 'input_file',
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,JVBERi0xLjQK',
},
],
});
expect(files).toEqual([
{ {
fileId: 'file-metapi-123', filename: 'brief.pdf',
filename: 'paper.pdf',
mimeType: 'application/pdf', mimeType: 'application/pdf',
}, data: 'data:application/pdf;base64,JVBERi0xLjQK',
{
filename: 'voice.mp3',
mimeType: 'audio/mpeg',
data: 'data:audio/mpeg;base64,QUFBQQ==',
}, },
]); ]);
});
expect(extractConversationUploadedFilesFromMessage(message)).toEqual([ it('extracts uploaded file references for retry flows', () => {
const files = extractConversationUploadedFilesFromMessage({
id: 'u2',
role: 'user',
content: '请继续分析',
createAt: 2,
parts: [
{
type: 'input_file',
fileId: 'file-metapi-456',
filename: 'appendix.txt',
mimeType: 'text/plain',
},
],
});
expect(files).toEqual([
{ {
fileId: 'file-metapi-123', fileId: 'file-metapi-456',
filename: 'paper.pdf', filename: 'appendix.txt',
mimeType: 'application/pdf', mimeType: 'text/plain',
},
{
filename: 'voice.mp3',
mimeType: 'audio/mpeg',
data: 'data:audio/mpeg;base64,QUFBQQ==',
}, },
]); ]);
}); });
+17 -8
View File
@@ -918,17 +918,26 @@ export const extractConversationUploadedFilesFromMessage = (
const parts = Array.isArray(message.parts) ? message.parts : []; const parts = Array.isArray(message.parts) ? message.parts : [];
return parts.flatMap((part) => { return parts.flatMap((part) => {
if (part.type !== 'input_file') return []; if (part.type !== 'input_file') return [];
const fileId = typeof part.fileId === 'string' ? part.fileId.trim() : '';
const data = typeof part.data === 'string' ? part.data.trim() : ''; const fileId = typeof part.fileId === 'string' && part.fileId.trim()
? part.fileId.trim()
: null;
const filename = typeof part.filename === 'string' && part.filename.trim()
? part.filename.trim()
: null;
const mimeType = typeof part.mimeType === 'string' && part.mimeType.trim()
? part.mimeType.trim()
: null;
const data = typeof part.data === 'string' && part.data.trim()
? part.data.trim()
: null;
if (!fileId && !data) return []; if (!fileId && !data) return [];
return [{ return [{
...(fileId ? { fileId } : {}), ...(fileId ? { fileId } : {}),
...(typeof part.filename === 'string' && part.filename.trim() ...(filename ? { filename } : {}),
? { filename: part.filename.trim() } ...(mimeType ? { mimeType } : {}),
: {}),
...(typeof part.mimeType === 'string' && part.mimeType.trim()
? { mimeType: part.mimeType.trim() }
: {}),
...(data ? { data } : {}), ...(data ? { data } : {}),
}]; }];
}); });
+10 -1
View File
@@ -16,6 +16,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/', url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com', externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api', platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
customHeaders: '{"x-site-token":"alpha"}', customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false, useSystemProxy: false,
globalWeight: 1.2, globalWeight: 1.2,
@@ -29,6 +30,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/', url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com', externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api', platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
customHeaders: '{"x-site-token":"alpha"}', customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false, useSystemProxy: false,
globalWeight: 1.2, globalWeight: 1.2,
@@ -44,6 +46,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com', url: 'https://b.example.com',
externalCheckinUrl: '', externalCheckinUrl: '',
platform: 'one-api', platform: 'one-api',
proxyUrl: '',
useSystemProxy: true, useSystemProxy: true,
customHeaders: '', customHeaders: '',
globalWeight: 0.8, globalWeight: 0.8,
@@ -58,6 +61,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com', url: 'https://b.example.com',
externalCheckinUrl: '', externalCheckinUrl: '',
platform: 'one-api', platform: 'one-api',
proxyUrl: '',
useSystemProxy: true, useSystemProxy: true,
customHeaders: '', customHeaders: '',
globalWeight: 0.8, globalWeight: 0.8,
@@ -74,6 +78,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://c.example.com', url: 'https://c.example.com',
externalCheckinUrl: '', externalCheckinUrl: '',
platform: '', platform: '',
proxyUrl: '',
useSystemProxy: false, useSystemProxy: false,
customHeaders: '', customHeaders: '',
globalWeight: 1, globalWeight: 1,
@@ -85,16 +90,20 @@ describe('buildSiteSaveAction', () => {
it('does not expose deprecated apiKey in site editor state', () => { it('does not expose deprecated apiKey in site editor state', () => {
expect(emptySiteForm()).not.toHaveProperty('apiKey'); expect(emptySiteForm()).not.toHaveProperty('apiKey');
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]); expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
expect(emptySiteForm().proxyUrl).toBe('');
expect(siteFormFromSite({ expect(siteFormFromSite({
name: 'site-d', name: 'site-d',
url: 'https://d.example.com', url: 'https://d.example.com',
externalCheckinUrl: null, externalCheckinUrl: null,
platform: 'new-api', platform: 'new-api',
proxyUrl: null, proxyUrl: 'http://127.0.0.1:8080',
customHeaders: '{"x-site-token":"alpha"}', customHeaders: '{"x-site-token":"alpha"}',
globalWeight: 1, globalWeight: 1,
apiKey: 'sk-legacy-site-key', apiKey: 'sk-legacy-site-key',
})).not.toHaveProperty('apiKey'); })).not.toHaveProperty('apiKey');
expect(siteFormFromSite({
proxyUrl: 'http://127.0.0.1:8080',
}).proxyUrl).toBe('http://127.0.0.1:8080');
}); });
it('parses custom headers json into key value rows', () => { it('parses custom headers json into key value rows', () => {
+6 -1
View File
@@ -8,6 +8,7 @@ export type SiteForm = {
url: string; url: string;
externalCheckinUrl: string; externalCheckinUrl: string;
platform: string; platform: string;
proxyUrl: string;
useSystemProxy: boolean; useSystemProxy: boolean;
customHeaders: SiteCustomHeaderField[]; customHeaders: SiteCustomHeaderField[];
globalWeight: string; globalWeight: string;
@@ -22,6 +23,7 @@ export type SiteSavePayload = {
url: string; url: string;
externalCheckinUrl: string; externalCheckinUrl: string;
platform: string; platform: string;
proxyUrl: string;
useSystemProxy: boolean; useSystemProxy: boolean;
customHeaders: string; customHeaders: string;
globalWeight: number; globalWeight: number;
@@ -45,6 +47,7 @@ export function emptySiteForm(): SiteForm {
url: '', url: '',
externalCheckinUrl: '', externalCheckinUrl: '',
platform: '', platform: '',
proxyUrl: '',
useSystemProxy: false, useSystemProxy: false,
customHeaders: [emptySiteCustomHeader()], customHeaders: [emptySiteCustomHeader()],
globalWeight: '1', globalWeight: '1',
@@ -76,8 +79,9 @@ function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
} }
} }
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'useSystemProxy'>> & { export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
proxyUrl?: string | null;
useSystemProxy?: boolean | null; useSystemProxy?: boolean | null;
customHeaders?: string | null; customHeaders?: string | null;
globalWeight?: number | string | null; globalWeight?: number | string | null;
@@ -89,6 +93,7 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' |
url: site.url ?? '', url: site.url ?? '',
externalCheckinUrl: site.externalCheckinUrl ?? '', externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '', platform: site.platform ?? '',
proxyUrl: site.proxyUrl ?? '',
useSystemProxy: !!site.useSystemProxy, useSystemProxy: !!site.useSystemProxy,
customHeaders: parseCustomHeadersForEditor(site.customHeaders), customHeaders: parseCustomHeadersForEditor(site.customHeaders),
globalWeight, globalWeight,
@@ -12,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getRoutesLite: vi.fn(), getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(), getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(), updateRuntimeSettings: vi.fn(),
testSystemProxy: vi.fn(),
}, },
})); }));
@@ -67,6 +68,16 @@ describe('Settings system proxy', () => {
success: true, success: true,
systemProxyUrl: 'http://127.0.0.1:7890', systemProxyUrl: 'http://127.0.0.1:7890',
}); });
apiMock.testSystemProxy.mockResolvedValue({
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,
latencyMs: 321,
});
}); });
afterEach(() => { afterEach(() => {
@@ -112,4 +123,45 @@ describe('Settings system proxy', () => {
root?.unmount(); root?.unmount();
} }
}); });
it('tests system proxy from settings', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const proxyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '系统代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)'
));
await act(async () => {
proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } });
});
const testButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '测试系统代理'
));
await act(async () => {
testButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.testSystemProxy).toHaveBeenCalledWith({
proxyUrl: 'http://127.0.0.1:7890',
});
expect(collectText(root.root)).toContain('连通成功,延迟 321 ms');
} finally {
root?.unmount();
}
});
}); });
@@ -7,6 +7,7 @@ import {
buildFixedTokenOptionDescription, buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel, buildFixedTokenOptionLabel,
describeTokenBinding, describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js'; } from './tokenBindingPresentation.js';
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js'; import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
@@ -54,7 +55,15 @@ export function SortableChannelRow({
}; };
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision); const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
const tokenBinding = describeTokenBinding(tokenOptions, activeTokenId, channel.token?.name ?? null); const tokenBinding = describeTokenBinding(
tokenOptions,
activeTokenId,
channel.token?.name ?? null,
{
connectionMode: resolveTokenBindingConnectionMode(channel.account),
accountName: channel.account?.username || `account-${channel.accountId}`,
},
);
return ( return (
<div ref={setNodeRef} style={rowStyle}> <div ref={setNodeRef} style={rowStyle}>
@@ -113,10 +122,10 @@ export function SortableChannelRow({
className="badge" className="badge"
style={{ style={{
fontSize: 10, fontSize: 10,
background: tokenBinding.isFollowingAccountDefault background: tokenBinding.badgeTone === 'info'
? 'color-mix(in srgb, var(--color-info) 15%, transparent)' ? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)', : 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: tokenBinding.isFollowingAccountDefault ? 'var(--color-info)' : 'var(--color-warning)', color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)',
}} }}
> >
{tokenBinding.bindingModeLabel} {tokenBinding.bindingModeLabel}
@@ -211,7 +220,7 @@ export function SortableChannelRow({
options={[ options={[
{ {
value: '0', value: '0',
label: '跟随账号默认', label: tokenBinding.followOptionLabel,
description: tokenBinding.followOptionDescription, description: tokenBinding.followOptionDescription,
}, },
...tokenOptions.map((token) => ({ ...tokenOptions.map((token) => ({
@@ -3,6 +3,7 @@ import {
buildFixedTokenOptionDescription, buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel, buildFixedTokenOptionLabel,
describeTokenBinding, describeTokenBinding,
resolveTokenBindingConnectionMode,
} from './tokenBindingPresentation.js'; } from './tokenBindingPresentation.js';
describe('tokenBindingPresentation', () => { describe('tokenBindingPresentation', () => {
@@ -13,8 +14,10 @@ describe('tokenBindingPresentation', () => {
], 0); ], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认'); expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('token-a'); expect(result.effectiveTokenName).toBe('token-a');
expect(result.helperText).toContain('以后账号默认变化时会自动切换'); expect(result.helperText).toContain('以后账号默认变化时会自动切换');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('当前生效:token-a'); expect(result.followOptionDescription).toContain('当前生效:token-a');
}); });
@@ -25,6 +28,7 @@ describe('tokenBindingPresentation', () => {
], 1); ], 1);
expect(result.bindingModeLabel).toBe('固定令牌'); expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('default'); expect(result.effectiveTokenName).toBe('default');
expect(result.helperText).toContain('它目前也是账号默认'); expect(result.helperText).toContain('它目前也是账号默认');
expect(result.helperText).toContain('不会跟着变'); expect(result.helperText).toContain('不会跟着变');
@@ -37,8 +41,10 @@ describe('tokenBindingPresentation', () => {
], 2); ], 2);
expect(result.bindingModeLabel).toBe('固定令牌'); expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.badgeTone).toBe('warning');
expect(result.effectiveTokenName).toBe('backup'); expect(result.effectiveTokenName).toBe('backup');
expect(result.helperText).toContain('不会随账号默认变化'); expect(result.helperText).toContain('不会随账号默认变化');
expect(result.followOptionLabel).toBe('跟随账号默认');
}); });
it('describes follow-account-default mode when no default token exists yet', () => { it('describes follow-account-default mode when no default token exists yet', () => {
@@ -48,11 +54,76 @@ describe('tokenBindingPresentation', () => {
], 0); ], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认'); expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.badgeTone).toBe('info');
expect(result.effectiveTokenName).toBe('未设置默认令牌'); expect(result.effectiveTokenName).toBe('未设置默认令牌');
expect(result.helperText).toContain('当前账号还没有默认令牌'); expect(result.helperText).toContain('当前账号还没有默认令牌');
expect(result.followOptionLabel).toBe('跟随账号默认');
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换'); expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
}); });
it('describes follow-api-setting mode for direct apikey connections', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'apikey',
accountName: 'elysiver_api',
});
expect(result.bindingModeLabel).toBe('API令牌');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('elysiver_api');
expect(result.helperText).toContain('连接「elysiver_api」保存的 API Key');
expect(result.followOptionLabel).toBe('固定使用:elysiver_api(跟随 API Key 设置)');
expect(result.followOptionDescription).toContain('API Key');
});
it('describes oauth direct binding without default-token wording', () => {
const result = describeTokenBinding([], 0, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('OAuth授权');
expect(result.badgeTone).toBe('warning');
expect(result.isFollowingAccountDefault).toBe(false);
expect(result.effectiveTokenName).toBe('mail@urlk.cn');
expect(result.helperText).toContain('OAuth 授权');
expect(result.helperText).not.toContain('默认令牌');
expect(result.followOptionLabel).toBe('固定使用:mail@urlk.cn(OAuth 授权)');
expect(result.followOptionDescription).toContain('OAuth 授权');
});
it('uses oauth fallback copy when a fixed token route is switched back to oauth direct binding', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 2, null, {
connectionMode: 'oauth',
accountName: 'mail@urlk.cn',
});
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.followOptionLabel).toBe('OAuth授权');
expect(result.followOptionDescription).toContain('mail@urlk.cn');
expect(result.followOptionDescription).not.toContain('当前生效:default');
});
it('resolves token binding connection mode from stored account fields', () => {
expect(resolveTokenBindingConnectionMode({
accessToken: '',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
})).toBe('apikey');
expect(resolveTokenBindingConnectionMode({
accessToken: 'session-token',
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe('session');
expect(resolveTokenBindingConnectionMode({
accessToken: 'oauth-access-token',
extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }),
})).toBe('oauth');
});
it('formats fixed token options with clearer labels and descriptions', () => { it('formats fixed token options with clearer labels and descriptions', () => {
const token = { const token = {
id: 3, id: 3,
@@ -8,11 +8,93 @@ export type TokenBindingOption = {
export type TokenBindingPresentation = { export type TokenBindingPresentation = {
isFollowingAccountDefault: boolean; isFollowingAccountDefault: boolean;
bindingModeLabel: string; bindingModeLabel: string;
badgeTone: 'info' | 'warning';
effectiveTokenName: string; effectiveTokenName: string;
helperText: string; helperText: string;
followOptionLabel: string;
followOptionDescription: string; followOptionDescription: string;
}; };
type TokenBindingConnectionMode = 'session' | 'apikey' | 'oauth';
type TokenBindingContext = {
connectionMode?: TokenBindingConnectionMode;
accountName?: string | null;
};
function parseExtraConfigHints(extraConfig?: string | null): {
credentialMode: Extract<TokenBindingConnectionMode, 'session' | 'apikey'> | null;
hasOauthProvider: boolean;
} {
if (typeof extraConfig !== 'string' || !extraConfig.trim()) {
return {
credentialMode: null,
hasOauthProvider: false,
};
}
try {
const parsed = JSON.parse(extraConfig) as {
credentialMode?: unknown;
oauth?: { provider?: unknown } | null;
};
const raw = String(parsed?.credentialMode || '').trim().toLowerCase();
return {
credentialMode: raw === 'session' || raw === 'apikey' ? raw : null,
hasOauthProvider: typeof parsed?.oauth?.provider === 'string' && parsed.oauth.provider.trim().length > 0,
};
} catch {}
return {
credentialMode: null,
hasOauthProvider: false,
};
}
function buildDirectBindingPresentation(
connectionMode: Extract<TokenBindingConnectionMode, 'apikey' | 'oauth'>,
accountName: string,
): TokenBindingPresentation {
if (connectionMode === 'oauth') {
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'OAuth授权',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」的 OAuth 授权,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(OAuth 授权)`,
followOptionDescription: `直接使用连接「${accountName}」的 OAuth 授权`,
};
}
return {
isFollowingAccountDefault: false,
bindingModeLabel: 'API令牌',
badgeTone: 'warning',
effectiveTokenName: accountName,
helperText: `当前直接使用连接「${accountName}」保存的 API Key,不依赖账号令牌。`,
followOptionLabel: `固定使用:${accountName}(跟随 API Key 设置)`,
followOptionDescription: `直接使用连接「${accountName}」保存的 API Key`,
};
}
export function resolveTokenBindingConnectionMode(account?: {
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
} | null): TokenBindingConnectionMode {
const parsedHints = parseExtraConfigHints(account?.extraConfig);
if (parsedHints.hasOauthProvider) return 'oauth';
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'session') return 'session';
if (rawMode === 'apikey') return 'apikey';
if (parsedHints.credentialMode) return parsedHints.credentialMode;
return typeof account?.accessToken === 'string' && account.accessToken.trim()
? 'session'
: 'apikey';
}
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null { export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
return options.find((option) => option.isDefault) || null; return options.find((option) => option.isDefault) || null;
} }
@@ -21,21 +103,30 @@ export function describeTokenBinding(
options: TokenBindingOption[], options: TokenBindingOption[],
activeTokenId: number, activeTokenId: number,
fallbackTokenName?: string | null, fallbackTokenName?: string | null,
context: TokenBindingContext = {},
): TokenBindingPresentation { ): TokenBindingPresentation {
const defaultToken = getDefaultTokenOption(options); const defaultToken = getDefaultTokenOption(options);
const selectedToken = activeTokenId const selectedToken = activeTokenId
? options.find((option) => option.id === activeTokenId) || null ? options.find((option) => option.id === activeTokenId) || null
: null; : null;
const connectionMode = context.connectionMode || 'session';
const accountName = String(context.accountName || '').trim() || '当前连接';
if (!activeTokenId) { if (!activeTokenId) {
if (connectionMode === 'apikey' || connectionMode === 'oauth') {
return buildDirectBindingPresentation(connectionMode, accountName);
}
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌'; const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
return { return {
isFollowingAccountDefault: true, isFollowingAccountDefault: true,
bindingModeLabel: '跟随账号默认', bindingModeLabel: '跟随账号默认',
badgeTone: 'info',
effectiveTokenName, effectiveTokenName,
helperText: defaultToken helperText: defaultToken
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。` ? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
: '跟随账号默认。当前账号还没有默认令牌。', : '跟随账号默认。当前账号还没有默认令牌。',
followOptionLabel: '跟随账号默认',
followOptionDescription: defaultToken followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换` ? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换', : '以后账号默认变化时会自动切换',
@@ -46,13 +137,21 @@ export function describeTokenBinding(
return { return {
isFollowingAccountDefault: false, isFollowingAccountDefault: false,
bindingModeLabel: '固定令牌', bindingModeLabel: '固定令牌',
badgeTone: 'warning',
effectiveTokenName, effectiveTokenName,
helperText: selectedToken?.isDefault helperText: selectedToken?.isDefault
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。` ? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`, : `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
followOptionDescription: defaultToken followOptionLabel: connectionMode === 'oauth'
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换` ? 'OAuth授权'
: '以后账号默认变化时会自动切换', : (connectionMode === 'apikey' ? 'API 设置' : '跟随账号默认'),
followOptionDescription: connectionMode === 'oauth'
? `直接使用连接「${accountName}」的 OAuth 授权`
: (connectionMode === 'apikey'
? `直接使用连接「${accountName}」保存的 API Key`
: (defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换')),
}; };
} }
+3
View File
@@ -28,6 +28,9 @@ export type RouteChannel = {
cooldownUntil?: string | null; cooldownUntil?: string | null;
account?: { account?: {
username: string | null; username: string | null;
accessToken?: string | null;
extraConfig?: string | null;
credentialMode?: string | null;
}; };
site?: { site?: {
id: number; id: number;