feat(server): add proactive channel probes and load-aware stable routing / 服务端新增主动探活与负载感知稳定路由 (#330)

* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* feat(proxy): add sticky session channel coordination

* feat(server): add proactive channel probes and load-aware stable routing

* test(server): stabilize session load routing selection assertion

* fix(proxy): harden codex forwarding stability

* fix(router): rotate stable-first channels by healthy order

* fix(server): address probe review feedback

* fix(server): address probe review follow-ups

* fix(ci): address CodeQL and core test failures

* feat(server): refine stable-first routing recovery and observation

* Finish PR 330 settings and review fixes

* Fix chat site endpoint CI mock

* Address latest PR 330 review comments

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
This commit is contained in:
Bainily
2026-04-02 09:49:04 +08:00
committed by GitHub
co-authored by Cita
parent 1133846998
commit 596d1b93cb
75 changed files with 6365 additions and 507 deletions
+4
View File
@@ -8,6 +8,10 @@ SERVERCHAN_KEY=
TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
MODEL_AVAILABILITY_PROBE_CONCURRENCY=1
MODEL_AVAILABILITY_PROBE_ENABLED=false
MODEL_AVAILABILITY_PROBE_INTERVAL_MS=1800000
MODEL_AVAILABILITY_PROBE_TIMEOUT_MS=15000
PORT=4000
DATA_DIR=./data
NOTIFY_COOLDOWN_SEC=300
+19
View File
@@ -128,6 +128,25 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|--------|------|--------|------|
| `TOKEN_ROUTER_CACHE_TTL_MS` | Token 路由缓存 TTL(毫秒) | `1500` | >= 100 |
### 模型弹活探测
用于异步校正“上游 `/models` 能看到,但实际调用不可用”的假阳性模型。
| 变量名 | 说明 | 默认值 | 范围 |
|--------|------|--------|------|
| `MODEL_AVAILABILITY_PROBE_ENABLED` | 启用后台模型可用性探测 | `false` | `true` / `false` |
| `MODEL_AVAILABILITY_PROBE_INTERVAL_MS` | 后台定时探测间隔(毫秒) | `1800000` | >= 60000 |
| `MODEL_AVAILABILITY_PROBE_TIMEOUT_MS` | 单次探测超时(毫秒) | `15000` | >= 3000 |
| `MODEL_AVAILABILITY_PROBE_CONCURRENCY` | 单账号模型探测并发数,默认串行避免对上游形成批量测活 | `1` | 1 ~ 16 |
说明:
- 探测任务在后台异步执行,不阻塞前台请求。
- 只对看起来像对话/推理模型的条目发最小化探测请求,明显的 embedding、rerank、moderation、tts、image/video 等模型会先跳过。
- 只有“明确模型不存在 / 不支持 / 无权限”的错误才会写回不可用;超时、5xx、网络抖动会归为“不确定”,不会误杀现有模型。
- 为了避免对上游形成意外的批量测活,默认关闭;只有显式开启后才会按间隔执行,且不会在服务启动后立刻扫全量模型。
- 同一批探测任务带去重,上一轮未结束时不会并发堆积;默认并发为 `1`,建议只在确认上游允许主动探测时启用。
### 路由预设建议
| 场景 | COST_WEIGHT | BALANCE_WEIGHT | USAGE_WEIGHT | 说明 |
+4
View File
@@ -140,6 +140,10 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
proxyDebugTargetModel: (env.PROXY_DEBUG_TARGET_MODEL || '').trim(),
proxyDebugRetentionHours: Math.max(1, Math.trunc(parseNumber(env.PROXY_DEBUG_RETENTION_HOURS, 24))),
proxyDebugMaxBodyBytes: Math.max(1024, Math.trunc(parseNumber(env.PROXY_DEBUG_MAX_BODY_BYTES, 262_144))),
modelAvailabilityProbeEnabled: parseBoolean(env.MODEL_AVAILABILITY_PROBE_ENABLED, false),
modelAvailabilityProbeIntervalMs: Math.max(60_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_INTERVAL_MS, 30 * 60 * 1000))),
modelAvailabilityProbeTimeoutMs: Math.max(3_000, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_TIMEOUT_MS, 15_000))),
modelAvailabilityProbeConcurrency: Math.max(1, Math.min(16, Math.trunc(parseNumber(env.MODEL_AVAILABILITY_PROBE_CONCURRENCY, 1)))),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))),
@@ -4,6 +4,7 @@ const backupExportTypeSchema = z.enum(['all', 'accounts', 'preferences']);
const migrationDialectSchema = z.enum(['sqlite', 'mysql', 'postgres']);
const runtimeSettingsPayloadSchema = z.object({
modelAvailabilityProbeEnabled: z.boolean().optional(),
webhookEnabled: z.boolean().optional(),
barkEnabled: z.boolean().optional(),
serverChanEnabled: z.boolean().optional(),
@@ -86,6 +87,9 @@ function formatSettingsPayloadError(error: z.ZodError): string {
if (firstPath === 'webhookEnabled') {
return 'Webhook 开关格式无效:需要 boolean';
}
if (firstPath === 'modelAvailabilityProbeEnabled') {
return '批量测活开关格式无效:需要 boolean';
}
if (firstPath === 'barkEnabled') {
return 'Bark 开关格式无效:需要 boolean';
}
+17
View File
@@ -38,6 +38,14 @@ import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackf
import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js';
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js';
import { startSiteAnnouncementPolling, stopSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
import {
startModelAvailabilityProbeScheduler,
stopModelAvailabilityProbeScheduler,
} from './services/modelAvailabilityProbeService.js';
import {
startChannelRecoveryProbeScheduler,
stopChannelRecoveryProbeScheduler,
} from './services/channelRecoveryProbeService.js';
import { startUpdateCenterPolling, stopUpdateCenterPolling } from './services/updateCenterPollingService.js';
import { reloadBackupWebdavScheduler } from './services/backupService.js';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
@@ -144,6 +152,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url');
if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl;
const modelAvailabilityProbeEnabled = parseSettingFromMap<boolean>(settingsMap, 'model_availability_probe_enabled');
if (typeof modelAvailabilityProbeEnabled === 'boolean') {
config.modelAvailabilityProbeEnabled = modelAvailabilityProbeEnabled;
}
const codexUpstreamWebsocketEnabled = parseSettingFromMap<boolean>(settingsMap, 'codex_upstream_websocket_enabled');
if (typeof codexUpstreamWebsocketEnabled === 'boolean') {
config.codexUpstreamWebsocketEnabled = codexUpstreamWebsocketEnabled;
@@ -494,6 +507,8 @@ if (existsSync(webDir)) {
await startScheduler();
await reloadBackupWebdavScheduler();
startSiteAnnouncementPolling();
startModelAvailabilityProbeScheduler();
startChannelRecoveryProbeScheduler();
startUpdateCenterPolling();
try {
await startOAuthLoopbackCallbackServers();
@@ -507,6 +522,8 @@ app.addHook('onClose', async () => {
stopUpdateCenterPolling();
stopProxyFileRetentionService();
stopProxyLogRetentionService();
stopModelAvailabilityProbeScheduler();
stopChannelRecoveryProbeScheduler();
await stopOAuthLoopbackCallbackServers();
});
@@ -0,0 +1,233 @@
import { fetch } from 'undici';
import { readRuntimeResponseText } from '../executors/types.js';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import {
buildUpstreamUrl,
summarizeUpstreamError,
type UpstreamEndpoint,
} from './upstreamRequest.js';
export type BuiltEndpointRequest = {
endpoint: UpstreamEndpoint;
path: string;
headers: Record<string, string>;
body: Record<string, unknown>;
runtime?: {
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
modelName?: string;
stream?: boolean;
oauthProjectId?: string | null;
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
};
};
export type EndpointAttemptContext = {
endpointIndex: number;
endpointCount: number;
request: BuiltEndpointRequest;
targetUrl: string;
response: Awaited<ReturnType<typeof fetch>>;
rawErrText: string;
recoverApplied?: boolean;
};
export type EndpointAttemptSuccessContext = {
endpointIndex: number;
endpointCount: number;
request: BuiltEndpointRequest;
targetUrl: string;
response: Awaited<ReturnType<typeof fetch>>;
recoverApplied?: boolean;
};
export type EndpointRecoverResult = {
upstream: Awaited<ReturnType<typeof fetch>>;
upstreamPath: string;
request?: BuiltEndpointRequest;
targetUrl?: string;
} | null;
export type EndpointFlowResult =
| {
ok: true;
upstream: Awaited<ReturnType<typeof fetch>>;
upstreamPath: string;
}
| {
ok: false;
status: number;
errText: string;
rawErrText?: string;
};
export type ExecuteEndpointFlowInput = {
siteUrl: string;
proxyUrl?: string | null;
disableCrossProtocolFallback?: boolean;
endpointCandidates: UpstreamEndpoint[];
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
dispatchRequest?: (
request: BuiltEndpointRequest,
targetUrl: string,
) => Promise<Awaited<ReturnType<typeof fetch>>>;
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
shouldAbortRemainingEndpoints?: (ctx: EndpointAttemptContext & { errText: string }) => boolean;
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
};
export function withUpstreamPath(path: string, message: string): string {
return `[upstream:${path}] ${message}`;
}
async function runEndpointFlowHook<T>(
hook: ((ctx: T) => void | Promise<void>) | undefined,
ctx: T,
hookName: string,
): Promise<void> {
if (!hook) return;
try {
await hook(ctx);
} catch (error) {
console.error(`endpointFlow ${hookName} hook failed`, error);
}
}
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
const endpointCount = input.endpointCandidates.length;
if (endpointCount <= 0) {
return {
ok: false,
status: 502,
errText: 'Upstream request failed',
};
}
let finalStatus = 0;
let finalErrText = 'unknown error';
let finalRawErrText: string | undefined;
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
const request = input.buildRequest(endpoint, endpointIndex);
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
const targetUrl = input.proxyUrl
? buildUpstreamUrl(input.proxyUrl, request.path)
: defaultTarget;
let response = input.dispatchRequest
? await input.dispatchRequest(request, targetUrl)
: await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(request.body),
}));
if (response.ok) {
await runEndpointFlowHook(input.onAttemptSuccess, {
endpointIndex,
endpointCount,
request,
targetUrl,
response,
recoverApplied: false,
}, 'onAttemptSuccess');
return {
ok: true,
upstream: response,
upstreamPath: request.path,
};
}
let rawErrText = await readRuntimeResponseText(response).catch(() => 'unknown error');
const baseContext: EndpointAttemptContext = {
endpointIndex,
endpointCount,
request,
targetUrl,
response,
rawErrText,
recoverApplied: false,
};
if (input.tryRecover) {
const recovered = await input.tryRecover(baseContext);
baseContext.recoverApplied = recovered !== null
|| baseContext.request !== request
|| baseContext.response !== response
|| baseContext.rawErrText !== rawErrText;
if (recovered?.upstream?.ok) {
const recoveredRequest = recovered.request ?? baseContext.request;
const recoveredTargetUrl = recovered.targetUrl ?? (
input.proxyUrl
? buildUpstreamUrl(input.proxyUrl, recovered.upstreamPath)
: buildUpstreamUrl(input.siteUrl, recovered.upstreamPath)
);
await runEndpointFlowHook(input.onAttemptSuccess, {
endpointIndex,
endpointCount,
request: recoveredRequest,
targetUrl: recoveredTargetUrl,
response: recovered.upstream,
recoverApplied: true,
}, 'onAttemptSuccess');
return {
ok: true,
upstream: recovered.upstream,
upstreamPath: recovered.upstreamPath,
};
}
}
rawErrText = baseContext.rawErrText;
response = baseContext.response;
const errText = withUpstreamPath(
baseContext.request.path,
summarizeUpstreamError(response.status, rawErrText),
);
await runEndpointFlowHook(input.onAttemptFailure, {
...baseContext,
errText,
}, 'onAttemptFailure');
const isLastEndpoint = endpointIndex >= endpointCount - 1;
if (input.disableCrossProtocolFallback && !isLastEndpoint) {
finalStatus = response.status;
finalErrText = errText;
finalRawErrText = rawErrText;
break;
}
const shouldAbortRemainingEndpoints = !isLastEndpoint && !!input.shouldAbortRemainingEndpoints?.({
...baseContext,
errText,
});
if (shouldAbortRemainingEndpoints) {
finalStatus = response.status;
finalErrText = errText;
finalRawErrText = rawErrText;
break;
}
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
if (shouldDowngrade) {
await runEndpointFlowHook(input.onDowngrade, {
...baseContext,
errText,
}, 'onDowngrade');
continue;
}
finalStatus = response.status;
finalErrText = errText;
finalRawErrText = rawErrText;
break;
}
return {
ok: false,
status: finalStatus || 502,
errText: finalErrText || 'unknown error',
rawErrText: finalRawErrText,
};
}
@@ -0,0 +1,138 @@
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function collapseWhitespace(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
function extractJsonErrorMessage(rawText: string): string {
try {
const parsed = JSON.parse(rawText) as unknown;
const root = isRecord(parsed) ? parsed : null;
const error = (root && isRecord(root.error)) ? root.error : root;
if (!error) return '';
const message = typeof error.message === 'string' ? collapseWhitespace(error.message) : '';
if (message) return message;
const code = typeof error.code === 'string' ? collapseWhitespace(error.code) : '';
const type = typeof error.type === 'string' ? collapseWhitespace(error.type) : '';
return [type, code].filter((part) => part.length > 0).join('/');
} catch {
return '';
}
}
function extractHtmlTitle(rawText: string): string {
const match = rawText.match(/<title[^>]*>([^<>]*)<\/title>/i);
if (!match?.[1]) return '';
return collapseWhitespace(match[1]);
}
function extractCloudflareHtmlSummary(rawText: string, status: number): string {
if (!/cloudflare/i.test(rawText)) return '';
const title = extractHtmlTitle(rawText);
const codeMatch = (
title.match(/\b(\d{3,4})\s*:\s*([^\|<]+)/i)
|| rawText.match(/Error code\s*(\d{3,4})/i)
);
const code = codeMatch?.[1] || (status > 0 ? String(status) : '');
const reason = collapseWhitespace(
(typeof codeMatch?.[2] === 'string' ? codeMatch[2] : '')
|| (status >= 500 ? 'origin host error' : 'request blocked')
);
if (code) return `Cloudflare ${code}: ${reason}`;
return `Cloudflare: ${reason}`;
}
function extractHtmlSummary(rawText: string, status: number): string {
if (!/(<!doctype|<html)/i.test(rawText)) return '';
const cloudflareSummary = extractCloudflareHtmlSummary(rawText, status);
if (cloudflareSummary) return cloudflareSummary;
const title = extractHtmlTitle(rawText);
if (title) return title;
const heading = rawText.match(/<h1[^>]*>([^<>]*)<\/h1>/i)?.[1] || '';
return collapseWhitespace(heading);
}
function formatUrlOrigin(url: URL): string {
const username = url.username ? encodeURIComponent(url.username) : '';
const password = url.password ? encodeURIComponent(url.password) : '';
const auth = username
? `${username}${password ? `:${password}` : ''}@`
: '';
return `${url.protocol}//${auth}${url.host}`;
}
function joinPath(basePath: string, requestPath: string): string {
const base = basePath.replace(/\/+$/, '');
const path = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
if (!base || base === '/') return path || '/';
if (!path || path === '/') return base;
return `${base}${path}`;
}
export function summarizeUpstreamError(status: number, rawErrorText: string): string {
const statusPrefix = status > 0
? `Upstream returned HTTP ${status}`
: 'Upstream request failed';
const raw = typeof rawErrorText === 'string' ? rawErrorText.trim() : '';
if (!raw) return statusPrefix;
const jsonMessage = extractJsonErrorMessage(raw);
if (jsonMessage) return `${statusPrefix}: ${jsonMessage}`;
const htmlMessage = extractHtmlSummary(raw, status);
if (htmlMessage) return `${statusPrefix}: ${htmlMessage}`;
const compact = collapseWhitespace(raw);
if (!compact) return statusPrefix;
if (compact.length <= 400) return `${statusPrefix}: ${compact}`;
return `${statusPrefix}: ${compact.slice(0, 400)}...(truncated)`;
}
export function buildUpstreamUrl(siteUrl: string, requestPath: string): string {
const baseRaw = typeof siteUrl === 'string' ? siteUrl.trim() : '';
const pathRaw = typeof requestPath === 'string' ? requestPath.trim() : '';
const fallbackBase = baseRaw.replace(/\/+$/, '');
let path = pathRaw.startsWith('/') ? pathRaw : `/${pathRaw}`;
if (!fallbackBase) return path || '/';
if (!path || path === '/') return fallbackBase;
try {
const parsed = new URL(baseRaw);
const basePath = parsed.pathname.replace(/\/+$/, '');
const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(basePath);
if (baseHasVersionSuffix) {
if (path === '/v1') {
path = '/';
} else if (path.startsWith('/v1/')) {
path = path.slice('/v1'.length) || '/';
}
}
const joinedPath = joinPath(basePath, path);
return `${formatUrlOrigin(parsed)}${joinedPath}${parsed.search}${parsed.hash}`;
} catch {
const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(fallbackBase);
if (baseHasVersionSuffix) {
if (path === '/v1') {
path = '/';
} else if (path.startsWith('/v1/')) {
path = path.slice('/v1'.length) || '/';
}
}
return `${fallbackBase}${path}`;
}
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import {
buildCodexSessionResponseStoreKey,
getCodexSessionResponseId,
resetCodexSessionResponseStore,
setCodexSessionResponseId,
} from './codexSessionResponseStore.js';
describe('codexSessionResponseStore', () => {
it('evicts the oldest session id when the store exceeds the cap', () => {
resetCodexSessionResponseStore();
for (let index = 0; index <= 10_000; index += 1) {
setCodexSessionResponseId(`session-${index}`, `resp-${index}`);
}
expect(getCodexSessionResponseId('session-0')).toBeNull();
expect(getCodexSessionResponseId('session-1')).toBe('resp-1');
expect(getCodexSessionResponseId('session-10000')).toBe('resp-10000');
resetCodexSessionResponseStore();
});
it('namespaces identical downstream session ids by channel scope', () => {
resetCodexSessionResponseStore();
const keyA = buildCodexSessionResponseStoreKey({
sessionId: 'session-1',
siteId: 10,
accountId: 20,
channelId: 30,
});
const keyB = buildCodexSessionResponseStoreKey({
sessionId: 'session-1',
siteId: 10,
accountId: 21,
channelId: 31,
});
setCodexSessionResponseId(keyA, 'resp-a');
setCodexSessionResponseId(keyB, 'resp-b');
expect(getCodexSessionResponseId(keyA)).toBe('resp-a');
expect(getCodexSessionResponseId(keyB)).toBe('resp-b');
resetCodexSessionResponseStore();
});
});
@@ -0,0 +1,55 @@
const MAX_CODEX_SESSION_RESPONSE_IDS = 10_000;
const codexSessionResponseIds = new Map<string, string>();
function normalizeSessionId(sessionId: string): string {
return sessionId.trim();
}
export function buildCodexSessionResponseStoreKey(input: {
sessionId: string;
siteId?: number | null;
accountId?: number | null;
channelId?: number | null;
}): string {
const normalizedSessionId = normalizeSessionId(input.sessionId);
if (!normalizedSessionId) return '';
const parts = [
Number.isFinite(input.siteId as number) && Number(input.siteId) > 0 ? `site:${Math.trunc(Number(input.siteId))}` : '',
Number.isFinite(input.accountId as number) && Number(input.accountId) > 0 ? `account:${Math.trunc(Number(input.accountId))}` : '',
Number.isFinite(input.channelId as number) && Number(input.channelId) > 0 ? `channel:${Math.trunc(Number(input.channelId))}` : '',
`session:${normalizedSessionId}`,
].filter(Boolean);
return parts.join('|');
}
export function getCodexSessionResponseId(sessionId: string): string | null {
const normalized = normalizeSessionId(sessionId);
if (!normalized) return null;
return codexSessionResponseIds.get(normalized) ?? null;
}
export function setCodexSessionResponseId(sessionId: string, responseId: string): void {
const normalizedSessionId = normalizeSessionId(sessionId);
const normalizedResponseId = responseId.trim();
if (!normalizedSessionId || !normalizedResponseId) return;
if (codexSessionResponseIds.has(normalizedSessionId)) {
codexSessionResponseIds.delete(normalizedSessionId);
}
codexSessionResponseIds.set(normalizedSessionId, normalizedResponseId);
while (codexSessionResponseIds.size > MAX_CODEX_SESSION_RESPONSE_IDS) {
const oldestKey = codexSessionResponseIds.keys().next().value;
if (!oldestKey) break;
codexSessionResponseIds.delete(oldestKey);
}
}
export function clearCodexSessionResponseId(sessionId: string): void {
const normalized = normalizeSessionId(sessionId);
if (!normalized) return;
codexSessionResponseIds.delete(normalized);
}
export function resetCodexSessionResponseStore(): void {
codexSessionResponseIds.clear();
}
@@ -1,6 +1,7 @@
import { AddressInfo } from 'node:net';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { WebSocketServer } from 'ws';
import { resetCodexSessionResponseStore } from './codexSessionResponseStore.js';
describe('codexWebsocketRuntime', () => {
let upstreamServer: WebSocketServer;
@@ -25,6 +26,7 @@ describe('codexWebsocketRuntime', () => {
});
beforeEach(() => {
resetCodexSessionResponseStore();
upstreamConnectionCount = 0;
upstreamRequests = [];
upstreamMessageHandler = (socket, parsed, requestIndex) => {
@@ -1,10 +1,22 @@
import type { IncomingMessage } from 'node:http';
import WebSocket from 'ws';
import {
extractResponsesTerminalResponseId,
isResponsesPreviousResponseNotFoundError,
shouldInferResponsesPreviousResponseId,
stripResponsesPreviousResponseId,
withResponsesPreviousResponseId,
} from '../../transformers/openai/responses/continuation.js';
import {
buildCodexWebsocketHandshakeHeaders,
buildCodexWebsocketRequestBody,
toCodexWebsocketUrl,
} from './codexWebsocketHeaders.js';
import {
clearCodexSessionResponseId,
getCodexSessionResponseId,
setCodexSessionResponseId,
} from './codexSessionResponseStore.js';
import { createCodexWebsocketSessionStore } from './codexWebsocketSessionStore.js';
import type {
CodexWebsocketRuntimeResult,
@@ -190,6 +202,23 @@ function clearSessionSocket(session: CodexWebsocketSession, socket: WebSocket):
session.socketUrl = null;
}
function buildContinuationAwareRuntimeBody(
sessionId: string,
body: Record<string, unknown>,
): Record<string, unknown> {
const rememberedResponseId = getCodexSessionResponseId(sessionId);
if (!shouldInferResponsesPreviousResponseId(body, rememberedResponseId)) {
return body;
}
return withResponsesPreviousResponseId(body, rememberedResponseId);
}
function rememberSessionResponseId(sessionId: string, payload: unknown): void {
const responseId = extractResponsesTerminalResponseId(payload);
if (!responseId) return;
setCodexSessionResponseId(sessionId, responseId);
}
async function ensureSessionSocket(
session: CodexWebsocketSession,
input: CodexWebsocketRuntimeSendInput,
@@ -232,9 +261,11 @@ async function ensureSessionSocket(
};
}
async function sendSessionRequest(
async function sendSessionRequestAttempt(
session: CodexWebsocketSession,
input: CodexWebsocketRuntimeSendInput,
input: CodexWebsocketRuntimeSendInput & {
body: Record<string, unknown>;
},
): Promise<CodexWebsocketRuntimeResult> {
const { socket, reusedSession } = await ensureSessionSocket(session, input);
const events: Array<Record<string, unknown>> = [];
@@ -262,7 +293,13 @@ async function sendSessionRequest(
events.push(parsed);
if (!isTerminalEvent(parsed)) return;
if (settled) return;
if (isRuntimeErrorEvent(parsed)) {
if (
isRuntimeErrorEvent(parsed)
|| isResponsesPreviousResponseNotFoundError({
payload: parsed,
rawErrText: extractTerminalErrorMessage(parsed),
})
) {
settled = true;
cleanup();
clearSessionSocket(session, socket);
@@ -274,6 +311,7 @@ async function sendSessionRequest(
}));
return;
}
rememberSessionResponseId(session.sessionId, parsed);
settled = true;
cleanup();
resolve({
@@ -307,6 +345,43 @@ async function sendSessionRequest(
});
}
async function sendSessionRequest(
session: CodexWebsocketSession,
input: CodexWebsocketRuntimeSendInput,
): Promise<CodexWebsocketRuntimeResult> {
let currentBody = buildContinuationAwareRuntimeBody(session.sessionId, input.body);
let previousResponseRecoveryTried = false;
for (;;) {
try {
return await sendSessionRequestAttempt(session, {
...input,
body: currentBody,
});
} catch (error) {
if (
previousResponseRecoveryTried
|| !(error instanceof CodexWebsocketRuntimeError)
|| !isResponsesPreviousResponseNotFoundError({
payload: error.payload ?? error.events[error.events.length - 1],
rawErrText: error.message,
})
) {
throw error;
}
const previousResponseRecovery = stripResponsesPreviousResponseId(currentBody);
if (!previousResponseRecovery.removed) {
throw error;
}
previousResponseRecoveryTried = true;
clearCodexSessionResponseId(session.sessionId);
currentBody = previousResponseRecovery.body;
}
}
}
export function createCodexWebsocketRuntime(input?: {
sessionStore?: CodexWebsocketSessionStore;
}) {
@@ -334,6 +409,7 @@ export function createCodexWebsocketRuntime(input?: {
await closeSocket(session.socket);
session.socket = null;
session.socketUrl = null;
clearCodexSessionResponseId(sessionId);
},
async closeAllSessions(): Promise<void> {
+15 -2
View File
@@ -9,7 +9,7 @@ import {
buildClaudeCountTokensUpstreamRequest,
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
} from '../../services/upstreamEndpointRuntime.js';
import {
getUpstreamEndpointRuntimeStateSnapshot,
recordUpstreamEndpointFailure,
@@ -20,10 +20,11 @@ import {
getDownstreamRoutingPolicy,
recordDownstreamCostUsage,
} from '../../routes/proxy/downstreamPolicy.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../orchestration/endpointFlow.js';
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
import { shouldPreferResponsesForAnthropicContinuation } from '../../transformers/anthropic/messages/compatibility.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import {
ProxyInputFileResolutionError,
@@ -47,6 +48,7 @@ import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/con
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
import {
acquireSurfaceChannelLease,
bindSurfaceStickyChannel,
@@ -170,6 +172,10 @@ export async function handleChatSurfaceRequest(
}
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(resolvedOpenAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const wantsContinuationAwareResponses = (
downstreamFormat === 'claude'
&& shouldPreferResponsesForAnthropicContinuation(claudeOriginalBody)
);
const codexSessionCacheKey = deriveCodexSessionCacheKey({
downstreamFormat,
body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body,
@@ -283,6 +289,7 @@ export async function handleChatSurfaceRequest(
{
hasNonImageFileInput,
conversationFileSummary,
wantsContinuationAwareResponses,
},
),
];
@@ -297,6 +304,7 @@ export async function handleChatSurfaceRequest(
requestCapabilities: {
hasNonImageFileInput,
conversationFileSummary,
wantsContinuationAwareResponses,
},
};
await safeUpdateSurfaceProxyDebugCandidates(debugTrace, {
@@ -309,6 +317,7 @@ export async function handleChatSurfaceRequest(
stickyPreferredChannelId,
oauthProvider: oauth?.provider || null,
isCodexSite,
wantsContinuationAwareResponses,
},
});
const buildProviderHeaders = () => (
@@ -389,6 +398,10 @@ export async function handleChatSurfaceRequest(
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover,
shouldAbortRemainingEndpoints: (ctx) => shouldAbortSameSiteEndpointFallback(
ctx.response.status,
ctx.rawErrText || ctx.errText,
),
onAttemptFailure: async (ctx) => {
const memoryWrite = recordUpstreamEndpointFailure({
...endpointRuntimeContext,
@@ -14,12 +14,12 @@ import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refres
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../orchestration/endpointFlow.js';
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
import {
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
} from '../../services/upstreamEndpointRuntime.js';
import {
getUpstreamEndpointRuntimeStateSnapshot,
recordUpstreamEndpointFailure,
@@ -35,12 +35,13 @@ import {
unwrapGeminiCliPayload,
wrapGeminiCliRequest,
} from '../../routes/proxy/geminiCliCompat.js';
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
import {
buildSurfaceProxyDebugResponseHeaders,
captureSurfaceProxyDebugSuccessResponseBody,
@@ -1224,6 +1225,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover: endpointStrategy.tryRecover,
shouldAbortRemainingEndpoints: (ctx) => shouldAbortSameSiteEndpointFallback(
ctx.response.status,
ctx.rawErrText || ctx.errText,
),
onAttemptFailure: async (ctx) => {
const memoryWrite = recordUpstreamEndpointFailure({
...endpointRuntimeContext,
@@ -4,17 +4,24 @@ import { config } from '../../config.js';
import { reportProxyAllFailed } from '../../services/alertService.js';
import { hasProxyUsagePayload, mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import {
extractResponsesTerminalResponseId,
isResponsesPreviousResponseNotFoundError,
shouldInferResponsesPreviousResponseId,
stripResponsesPreviousResponseId,
withResponsesPreviousResponseId,
} from '../../transformers/openai/responses/continuation.js';
import {
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
} from '../../services/upstreamEndpointRuntime.js';
import {
getUpstreamEndpointRuntimeStateSnapshot,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
} from '../../services/upstreamEndpointRuntimeMemory.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../orchestration/endpointFlow.js';
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
@@ -39,12 +46,19 @@ import {
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
import { getRuntimeResponseReader, readRuntimeResponseText } from '../executors/types.js';
import { runCodexHttpSessionTask } from '../runtime/codexHttpSessionQueue.js';
import {
buildCodexSessionResponseStoreKey,
clearCodexSessionResponseId,
getCodexSessionResponseId,
setCodexSessionResponseId,
} from '../runtime/codexSessionResponseStore.js';
import {
summarizeConversationFileInputsInOpenAiBody,
summarizeConversationFileInputsInResponsesBody,
} from '../capabilities/conversationFileCapabilities.js';
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
import { shouldAbortSameSiteEndpointFallback } from '../../services/proxyRetryPolicy.js';
import {
acquireSurfaceChannelLease,
bindSurfaceStickyChannel,
@@ -96,6 +110,12 @@ function isResponsesWebsocketTransportRequest(headers: Record<string, unknown>):
&& String(rawValue).trim() === '1');
}
function rememberCodexSessionResponseId(sessionId: string, payload: unknown): void {
const responseId = extractResponsesTerminalResponseId(payload);
if (!responseId) return;
setCodexSessionResponseId(sessionId, responseId);
}
function normalizeIncludeList(value: unknown): string[] {
if (typeof value === 'string') {
const trimmed = value.trim();
@@ -345,6 +365,20 @@ export async function handleOpenAiResponsesSurfaceRequest(
const modelName = selected.actualModel || requestedModel;
const oauth = getOauthInfoFromAccount(selected.account);
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
const codexSessionId = isCodexSite
? getCodexSessionHeaderValue(request.headers as Record<string, string>)
: '';
const codexSessionStoreKey = (
isCodexSite
&& codexSessionId
)
? buildCodexSessionResponseStoreKey({
sessionId: codexSessionId,
siteId: selected.site.id,
accountId: selected.account.id,
channelId: selected.channel.id,
})
: '';
const owner = getProxyResourceOwner(request);
let normalizedResponsesBody: Record<string, unknown> = {
...requestEnvelope.parsed.normalizedBody,
@@ -427,6 +461,20 @@ export async function handleOpenAiResponsesSurfaceRequest(
const executeEndpointResultForSiteApiBaseUrl = async (siteApiBaseUrl: string) => {
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
const responsesOriginalBody = (
endpoint === 'responses'
&& isCodexSite
&& codexSessionStoreKey
&& shouldInferResponsesPreviousResponseId(
normalizedResponsesBody,
getCodexSessionResponseId(codexSessionStoreKey),
)
)
? withResponsesPreviousResponseId(
normalizedResponsesBody,
getCodexSessionResponseId(codexSessionStoreKey)!,
)
: normalizedResponsesBody;
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
@@ -438,7 +486,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
siteUrl: siteApiBaseUrl,
openaiBody: openAiBody,
downstreamFormat: 'responses',
responsesOriginalBody: normalizedResponsesBody,
responsesOriginalBody,
downstreamHeaders: request.headers as Record<string, unknown>,
providerHeaders: buildProviderHeaders(),
});
@@ -469,7 +517,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
}
const sessionId = getCodexSessionHeaderValue(endpointRequest.headers);
return runCodexHttpSessionTask(
sessionId,
codexSessionStoreKey || sessionId,
() => baseDispatchRequest(endpointRequest, targetUrl),
);
};
@@ -496,6 +544,35 @@ export async function handleOpenAiResponsesSurfaceRequest(
return recovered;
}
}
if (
ctx.request.endpoint === 'responses'
&& isResponsesPreviousResponseNotFoundError({
rawErrText: ctx.rawErrText,
})
) {
if (codexSessionStoreKey) {
clearCodexSessionResponseId(codexSessionStoreKey);
}
const previousResponseRecovery = stripResponsesPreviousResponseId(ctx.request.body);
if (previousResponseRecovery.removed) {
const recoveredRequest = {
...ctx.request,
body: previousResponseRecovery.body,
};
const recoveredResponse = await dispatchRequest(recoveredRequest, ctx.targetUrl);
if (recoveredResponse.ok) {
return {
upstream: recoveredResponse,
upstreamPath: recoveredRequest.path,
request: recoveredRequest,
targetUrl: ctx.targetUrl,
};
}
ctx.request = recoveredRequest;
ctx.response = recoveredResponse;
ctx.rawErrText = await readRuntimeResponseText(recoveredResponse).catch(() => 'unknown error');
}
}
return endpointStrategy.tryRecover(ctx);
};
@@ -507,6 +584,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover,
shouldAbortRemainingEndpoints: (ctx) => shouldAbortSameSiteEndpointFallback(
ctx.response.status,
ctx.rawErrText || ctx.errText,
),
onAttemptFailure: async (ctx) => {
const memoryWrite = recordUpstreamEndpointFailure({
...endpointRuntimeContext,
@@ -699,6 +780,9 @@ export async function handleOpenAiResponsesSurfaceRequest(
if (payload && typeof payload === 'object') {
upstreamUsagePresent = upstreamUsagePresent || hasProxyUsagePayload(payload);
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
if (codexSessionStoreKey) {
rememberCodexSessionResponseId(codexSessionStoreKey, payload);
}
}
},
writeLines,
@@ -762,6 +846,9 @@ export async function handleOpenAiResponsesSurfaceRequest(
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
if (codexSessionStoreKey) {
rememberCodexSessionResponseId(codexSessionStoreKey, upstreamData);
}
parsedUsage = parseProxyUsage(upstreamData);
upstreamUsagePresent = upstreamUsagePresent || hasProxyUsagePayload(upstreamData);
@@ -867,6 +954,9 @@ export async function handleOpenAiResponsesSurfaceRequest(
`event: ${terminalEventType}\ndata: ${JSON.stringify({ type: terminalEventType, response: collectedPayload })}\n\n`,
'data: [DONE]\n\n',
]);
if (codexSessionStoreKey) {
rememberCodexSessionResponseId(codexSessionStoreKey, collectedPayload);
}
reply.raw.end();
const latency = Date.now() - startTime;
await finalizeStreamSuccess(
@@ -1025,6 +1115,9 @@ export async function handleOpenAiResponsesSurfaceRequest(
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
if (codexSessionStoreKey) {
rememberCodexSessionResponseId(codexSessionStoreKey, upstreamData);
}
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData);
const upstreamUsagePresent = hasProxyUsagePayload(upstreamData);
@@ -85,6 +85,7 @@ vi.mock('../../services/alertRules.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: (...args: unknown[]) => shouldRetryProxyRequestMock(...args),
shouldAbortSameSiteEndpointFallback: () => false,
}));
vi.mock('../../services/oauth/quota.js', () => ({
@@ -11,9 +11,9 @@ import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
import type { DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import type { BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
import { dispatchRuntimeRequest } from '../../services/runtimeDispatch.js';
import type { BuiltEndpointRequest } from '../orchestration/endpointFlow.js';
import { buildUpstreamUrl } from '../orchestration/upstreamRequest.js';
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
import { proxyChannelCoordinator } from '../../services/proxyChannelCoordinator.js';
@@ -0,0 +1,106 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
const { startModelAvailabilityProbeSchedulerMock, stopModelAvailabilityProbeSchedulerMock } = vi.hoisted(() => ({
startModelAvailabilityProbeSchedulerMock: vi.fn(),
stopModelAvailabilityProbeSchedulerMock: vi.fn(),
}));
vi.mock('../../services/modelAvailabilityProbeService.js', () => ({
startModelAvailabilityProbeScheduler: startModelAvailabilityProbeSchedulerMock,
stopModelAvailabilityProbeScheduler: stopModelAvailabilityProbeSchedulerMock,
}));
type ConfigModule = typeof import('../../config.js');
type DbModule = typeof import('../../db/index.js');
describe('settings model availability probe runtime setting', () => {
let app: FastifyInstance;
let config: ConfigModule['config'];
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-model-probe-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const configModule = await import('../../config.js');
const settingsRoutesModule = await import('./settings.js');
db = dbModule.db;
schema = dbModule.schema;
config = configModule.config;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(async () => {
await db.delete(schema.settings).run();
config.modelAvailabilityProbeEnabled = false;
startModelAvailabilityProbeSchedulerMock.mockReset();
stopModelAvailabilityProbeSchedulerMock.mockReset();
});
afterAll(async () => {
await app.close();
rmSync(dataDir, { recursive: true, force: true });
delete process.env.DATA_DIR;
});
it('persists enabling the model availability probe and starts the scheduler', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
modelAvailabilityProbeEnabled: true,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { modelAvailabilityProbeEnabled?: boolean };
expect(updated.modelAvailabilityProbeEnabled).toBe(true);
expect(config.modelAvailabilityProbeEnabled).toBe(true);
expect(startModelAvailabilityProbeSchedulerMock).toHaveBeenCalledTimes(1);
expect(stopModelAvailabilityProbeSchedulerMock).not.toHaveBeenCalled();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'model_availability_probe_enabled')).get();
expect(saved?.value).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
expect((getResponse.json() as { modelAvailabilityProbeEnabled?: boolean }).modelAvailabilityProbeEnabled).toBe(true);
});
it('persists disabling the model availability probe and stops the scheduler', async () => {
config.modelAvailabilityProbeEnabled = true;
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
modelAvailabilityProbeEnabled: false,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { modelAvailabilityProbeEnabled?: boolean };
expect(updated.modelAvailabilityProbeEnabled).toBe(false);
expect(config.modelAvailabilityProbeEnabled).toBe(false);
expect(stopModelAvailabilityProbeSchedulerMock).toHaveBeenCalledTimes(1);
expect(startModelAvailabilityProbeSchedulerMock).not.toHaveBeenCalled();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'model_availability_probe_enabled')).get();
expect(saved?.value).toBe(JSON.stringify(false));
});
});
+39
View File
@@ -40,12 +40,17 @@ import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyReque
import { performFactoryReset } from '../../services/factoryResetService.js';
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
import {
startModelAvailabilityProbeScheduler,
stopModelAvailabilityProbeScheduler,
} from '../../services/modelAvailabilityProbeService.js';
type RoutingWeights = typeof config.routingWeights;
interface RuntimeSettingsBody {
proxyToken?: string;
systemProxyUrl?: string;
modelAvailabilityProbeEnabled?: boolean;
codexUpstreamWebsocketEnabled?: boolean;
disableCrossProtocolFallback?: boolean;
proxySessionChannelConcurrencyLimit?: number;
@@ -404,6 +409,16 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
return;
}
case 'model_availability_probe_enabled': {
if (typeof value !== 'boolean') return;
config.modelAvailabilityProbeEnabled = value;
if (value) {
startModelAvailabilityProbeScheduler();
} else {
stopModelAvailabilityProbeScheduler();
}
return;
}
case 'codex_upstream_websocket_enabled': {
if (typeof value !== 'boolean') return;
config.codexUpstreamWebsocketEnabled = value;
@@ -687,6 +702,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
logCleanupRetentionDays: config.logCleanupRetentionDays,
modelAvailabilityProbeEnabled: config.modelAvailabilityProbeEnabled,
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
@@ -1101,6 +1117,29 @@ export async function settingsRoutes(app: FastifyInstance) {
invalidateSiteProxyCache();
}
if (body.modelAvailabilityProbeEnabled !== undefined) {
let nextValue = false;
try {
nextValue = parseBooleanFlag(body.modelAvailabilityProbeEnabled, '批量测活开关');
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '批量测活开关格式无效',
});
}
if (nextValue !== config.modelAvailabilityProbeEnabled) {
changedLabels.push(nextValue ? '开启批量测活' : '关闭批量测活');
}
await upsertSetting('model_availability_probe_enabled', nextValue);
config.modelAvailabilityProbeEnabled = nextValue;
if (nextValue) {
startModelAvailabilityProbeScheduler();
} else {
stopModelAvailabilityProbeScheduler();
}
}
if (body.codexUpstreamWebsocketEnabled !== undefined) {
let nextValue = false;
try {
@@ -0,0 +1,157 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const buildModelAvailabilityProbeTaskDedupeKeyMock = vi.fn();
const queueModelAvailabilityProbeTaskMock = vi.fn();
const getBackgroundTaskMock = vi.fn();
const getRunningTaskByDedupeKeyMock = vi.fn();
const waitForBackgroundTaskCompletionMock = vi.fn();
vi.mock('../../services/modelAvailabilityProbeService.js', async () => {
const actual = await vi.importActual<typeof import('../../services/modelAvailabilityProbeService.js')>('../../services/modelAvailabilityProbeService.js');
return {
...actual,
buildModelAvailabilityProbeTaskDedupeKey: (...args: unknown[]) => buildModelAvailabilityProbeTaskDedupeKeyMock(...args),
queueModelAvailabilityProbeTask: (...args: unknown[]) => queueModelAvailabilityProbeTaskMock(...args),
};
});
vi.mock('../../services/backgroundTaskService.js', async () => {
const actual = await vi.importActual<typeof import('../../services/backgroundTaskService.js')>('../../services/backgroundTaskService.js');
return {
...actual,
getBackgroundTask: (...args: unknown[]) => getBackgroundTaskMock(...args),
getRunningTaskByDedupeKey: (...args: unknown[]) => getRunningTaskByDedupeKeyMock(...args),
waitForBackgroundTaskCompletion: (...args: unknown[]) => waitForBackgroundTaskCompletionMock(...args),
};
});
describe('/api/models/probe', () => {
let app: FastifyInstance;
let dataDir = '';
let originalDataDir: string | undefined;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-model-probe-'));
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const routesModule = await import('./stats.js');
app = Fastify();
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
buildModelAvailabilityProbeTaskDedupeKeyMock.mockReset();
queueModelAvailabilityProbeTaskMock.mockReset();
getBackgroundTaskMock.mockReset();
getRunningTaskByDedupeKeyMock.mockReset();
waitForBackgroundTaskCompletionMock.mockReset();
});
afterAll(async () => {
await app.close();
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
});
it('rejects non-object request bodies instead of defaulting to a full probe', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/models/probe',
headers: {
'content-type': 'application/json',
},
payload: '"oops"',
});
expect(response.statusCode).toBe(400);
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
});
it('rejects loosely formatted account ids instead of truncating them', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/models/probe',
payload: {
accountId: '1e2',
},
});
expect(response.statusCode).toBe(400);
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
});
it('rejects account ids that exceed the safe integer range', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/models/probe',
payload: {
accountId: '9007199254740993',
},
});
expect(response.statusCode).toBe(400);
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
});
it('reuses the running deduped task for wait=true requests', async () => {
buildModelAvailabilityProbeTaskDedupeKeyMock.mockReturnValue('model-availability-probe-7');
getRunningTaskByDedupeKeyMock.mockReturnValue({
id: 'task-7',
status: 'running',
});
const completedTask = {
id: 'task-7',
status: 'succeeded',
result: {
results: [],
summary: {
totalAccounts: 1,
success: 1,
failed: 0,
skipped: 0,
scanned: 3,
supported: 2,
unsupported: 1,
inconclusive: 0,
skippedModels: 0,
updatedRows: 1,
rebuiltRoutes: true,
},
},
};
waitForBackgroundTaskCompletionMock.mockResolvedValue(completedTask);
const response = await app.inject({
method: 'POST',
url: '/api/models/probe',
payload: {
accountId: 7,
wait: true,
},
});
expect(response.statusCode).toBe(200);
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
expect(waitForBackgroundTaskCompletionMock).toHaveBeenCalledWith('task-7');
expect(response.json()).toMatchObject({
success: true,
reused: true,
jobId: 'task-7',
summary: {
totalAccounts: 1,
scanned: 3,
supported: 2,
unsupported: 1,
},
});
});
});
+101 -1
View File
@@ -6,8 +6,18 @@ import { refreshModelsForAccount } from '../../services/modelService.js';
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js';
import {
buildModelAvailabilityProbeTaskDedupeKey,
queueModelAvailabilityProbeTask,
type ModelAvailabilityProbeExecutionResult,
} from '../../services/modelAvailabilityProbeService.js';
import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js';
import { getRunningTaskByDedupeKey, startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
getBackgroundTask,
getRunningTaskByDedupeKey,
startBackgroundTask,
waitForBackgroundTaskCompletion,
} from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
import {
@@ -39,6 +49,10 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
const limitModelTokenCandidatesRead = createRateLimitGuard({
@@ -1462,6 +1476,92 @@ export async function statsRoutes(app: FastifyInstance) {
return { success: true, refresh, rebuild };
});
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/models/probe', async (request, reply) => {
const requestBody = request.body;
if (requestBody !== undefined && !isRecord(requestBody)) {
return reply.code(400).send({ success: false, message: '请求体必须是对象' });
}
const rawAccountId = requestBody?.accountId as unknown;
const normalizedAccountId = rawAccountId === undefined || rawAccountId === null
? ''
: String(rawAccountId).trim();
const hasAccountId = normalizedAccountId !== '';
const parsedAccountId = hasAccountId && /^[1-9]\d*$/.test(normalizedAccountId)
? Number(normalizedAccountId)
: undefined;
const accountId = parsedAccountId !== undefined && Number.isSafeInteger(parsedAccountId)
? parsedAccountId
: undefined;
const wait = requestBody?.wait === true;
if (hasAccountId && accountId === undefined) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
if (wait) {
const taskTitle = accountId ? `探测模型可用性 #${accountId}` : '探测全部模型可用性';
const dedupeKey = buildModelAvailabilityProbeTaskDedupeKey(accountId);
const runningTask = getRunningTaskByDedupeKey(dedupeKey);
const { task, reused } = runningTask
? { task: runningTask, reused: true }
: queueModelAvailabilityProbeTask({
accountId,
title: taskTitle,
});
const completedTask = await waitForBackgroundTaskCompletion(task.id);
if (!completedTask) {
return reply.code(500).send({ success: false, message: '模型可用性探测任务不存在或已过期' });
}
if (completedTask.status === 'failed') {
return reply.code(500).send({
success: false,
reused,
jobId: completedTask.id,
status: completedTask.status,
message: completedTask.error || '模型可用性探测失败',
});
}
const result = completedTask.result as ModelAvailabilityProbeExecutionResult | null;
if (!result) {
return reply.code(500).send({
success: false,
reused,
jobId: completedTask.id,
status: completedTask.status,
message: '模型可用性探测结果为空',
});
}
if (accountId && result.summary.totalAccounts === 0) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
return {
success: true,
reused,
jobId: completedTask.id,
status: completedTask.status,
...result,
};
}
const taskTitle = accountId ? `探测模型可用性 #${accountId}` : '探测全部模型可用性';
const { task, reused } = queueModelAvailabilityProbeTask({
accountId,
title: taskTitle,
});
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
status: task.status,
message: reused
? '模型可用性探测任务进行中,请稍后查看任务列表'
: '已开始模型可用性探测,请稍后查看任务列表',
});
});
// Site distribution – per-site aggregate data
app.get('/api/stats/site-distribution', async () => {
const accountRows = await db.select({
@@ -104,6 +104,13 @@ describe('proxy route architecture boundaries', () => {
expect(source).not.toContain('export function getUpstreamEndpointRuntimeStateSnapshot(');
});
it('keeps endpoint flow orchestration owned by proxy-core instead of the route layer', () => {
const source = readSource('./endpointFlow.ts');
expect(source).toContain("from '../../proxy-core/orchestration/endpointFlow.js'");
expect(source).not.toContain('async function runEndpointFlowHook<');
expect(source).not.toContain('export async function executeEndpointFlow(');
});
it('keeps gemini runtime closure in transformer-owned helpers', () => {
const source = readSource('./gemini.ts');
const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
@@ -55,6 +55,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -59,6 +59,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -59,6 +59,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
+117
View File
@@ -60,6 +60,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -817,6 +818,122 @@ describe('chat proxy stream behavior', () => {
expect(response.body).toContain('event: message_stop');
});
it('normalizes null Claude message content before proxying on /v1/messages', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: {
name: 'claude-site',
url: 'https://upstream.example.com',
platform: 'claude',
},
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'claude-opus-4-6',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_1',
type: 'message',
role: 'assistant',
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'ok' }],
stop_reason: 'end_turn',
usage: { input_tokens: 1, output_tokens: 1 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: null },
],
},
});
expect(response.statusCode).toBe(200);
const forwardedBody = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
expect(forwardedBody.messages).toEqual([
{ role: 'user', content: 'hello' },
]);
});
it('prefers responses for Claude tool_result follow-ups that include continuation hints', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: {
name: 'openai-site',
url: 'https://upstream.example.com',
platform: 'openai',
},
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'gpt-5.4',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_1',
object: 'response',
model: 'gpt-5.4',
created_at: 1_706_000_000,
status: 'completed',
output: [{
id: 'msg_1',
type: 'message',
role: 'assistant',
status: 'completed',
content: [{ type: 'output_text', text: 'done' }],
}],
usage: {
input_tokens: 5,
output_tokens: 1,
total_tokens: 6,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
previous_response_id: 'resp_prev_1',
messages: [
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_missing',
content: [{ type: 'text', text: '{"matches":1}' }],
},
{ type: 'text', text: 'continue' },
],
},
],
},
});
expect(response.statusCode).toBe(200);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/v1/responses');
const forwardedBody = JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string);
expect(forwardedBody.previous_response_id).toBe('resp_prev_1');
expect(forwardedBody.input[0]).toEqual({
type: 'function_call_output',
call_id: 'toolu_missing',
output: '{"matches":1}',
});
});
it('converts OpenAI tool_calls SSE into Claude tool_use stream events on /v1/messages', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
@@ -58,6 +58,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -224,6 +224,37 @@ describe('executeEndpointFlow', () => {
expect(onAttemptSuccess.mock.calls[0]?.[0]?.request?.path).toBe('/v1/chat/completions');
});
it('stops same-site endpoint fallback when the failure is classified as a site outage', async () => {
fetchMock
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'Service temporarily unavailable', type: 'upstream_error' },
}), {
status: 503,
headers: { 'content-type': 'application/json' },
})))
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
const result = await executeEndpointFlow({
siteUrl: 'https://example.com',
endpointCandidates: ['responses', 'chat'],
buildRequest: (endpoint) => endpoint === 'responses'
? requestFor('/v1/responses')
: { ...requestFor('/v1/chat/completions'), endpoint },
shouldAbortRemainingEndpoints: () => true,
shouldDowngrade: () => true,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(503);
expect(result.errText).toContain('Service temporarily unavailable');
}
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('accepts recovered response from tryRecover hook', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' },
+12 -219
View File
@@ -1,220 +1,13 @@
import { fetch } from 'undici';
import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { summarizeUpstreamError } from './upstreamError.js';
import type { UpstreamEndpoint } from './upstreamEndpoint.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
export {
executeEndpointFlow,
withUpstreamPath,
} from '../../proxy-core/orchestration/endpointFlow.js';
export type BuiltEndpointRequest = {
endpoint: UpstreamEndpoint;
path: string;
headers: Record<string, string>;
body: Record<string, unknown>;
runtime?: {
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
modelName?: string;
stream?: boolean;
oauthProjectId?: string | null;
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
};
};
export type EndpointAttemptContext = {
endpointIndex: number;
endpointCount: number;
request: BuiltEndpointRequest;
targetUrl: string;
response: Awaited<ReturnType<typeof fetch>>;
rawErrText: string;
recoverApplied?: boolean;
};
export type EndpointAttemptSuccessContext = {
endpointIndex: number;
endpointCount: number;
request: BuiltEndpointRequest;
targetUrl: string;
response: Awaited<ReturnType<typeof fetch>>;
recoverApplied?: boolean;
};
export type EndpointRecoverResult = {
upstream: Awaited<ReturnType<typeof fetch>>;
upstreamPath: string;
request?: BuiltEndpointRequest;
targetUrl?: string;
} | null;
export type EndpointFlowResult =
| {
ok: true;
upstream: Awaited<ReturnType<typeof fetch>>;
upstreamPath: string;
}
| {
ok: false;
status: number;
errText: string;
rawErrText?: string;
};
export function withUpstreamPath(path: string, message: string): string {
return `[upstream:${path}] ${message}`;
}
type ExecuteEndpointFlowInput = {
siteUrl: string;
proxyUrl?: string | null;
disableCrossProtocolFallback?: boolean;
endpointCandidates: UpstreamEndpoint[];
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
dispatchRequest?: (
request: BuiltEndpointRequest,
targetUrl: string,
) => Promise<Awaited<ReturnType<typeof fetch>>>;
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
};
async function runEndpointFlowHook<T>(
hook: ((ctx: T) => void | Promise<void>) | undefined,
ctx: T,
hookName: string,
): Promise<void> {
if (!hook) return;
try {
await hook(ctx);
} catch (error) {
console.error(`endpointFlow ${hookName} hook failed`, error);
}
}
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
const endpointCount = input.endpointCandidates.length;
if (endpointCount <= 0) {
return {
ok: false,
status: 502,
errText: 'Upstream request failed',
};
}
let finalStatus = 0;
let finalErrText = 'unknown error';
let finalRawErrText: string | undefined;
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
const request = input.buildRequest(endpoint, endpointIndex);
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
const targetUrl = input.proxyUrl
? buildUpstreamUrl(input.proxyUrl, request.path)
: defaultTarget;
let response = input.dispatchRequest
? await input.dispatchRequest(request, targetUrl)
: await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(request.body),
}));
if (response.ok) {
await runEndpointFlowHook(input.onAttemptSuccess, {
endpointIndex,
endpointCount,
request,
targetUrl,
response,
recoverApplied: false,
}, 'onAttemptSuccess');
return {
ok: true,
upstream: response,
upstreamPath: request.path,
};
}
let rawErrText = await readRuntimeResponseText(response).catch(() => 'unknown error');
const baseContext: EndpointAttemptContext = {
endpointIndex,
endpointCount,
request,
targetUrl,
response,
rawErrText,
recoverApplied: false,
};
if (input.tryRecover) {
const recovered = await input.tryRecover(baseContext);
baseContext.recoverApplied = recovered !== null
|| baseContext.request !== request
|| baseContext.response !== response
|| baseContext.rawErrText !== rawErrText;
if (recovered?.upstream?.ok) {
const recoveredRequest = recovered.request ?? baseContext.request;
const recoveredTargetUrl = recovered.targetUrl ?? (
input.proxyUrl
? buildUpstreamUrl(input.proxyUrl, recovered.upstreamPath)
: buildUpstreamUrl(input.siteUrl, recovered.upstreamPath)
);
await runEndpointFlowHook(input.onAttemptSuccess, {
endpointIndex,
endpointCount,
request: recoveredRequest,
targetUrl: recoveredTargetUrl,
response: recovered.upstream,
recoverApplied: true,
}, 'onAttemptSuccess');
return {
ok: true,
upstream: recovered.upstream,
upstreamPath: recovered.upstreamPath,
};
}
}
// Normalize again in case recoverer performed additional probes and updated the response text.
rawErrText = baseContext.rawErrText;
response = baseContext.response;
const errText = withUpstreamPath(
baseContext.request.path,
summarizeUpstreamError(response.status, rawErrText),
);
await runEndpointFlowHook(input.onAttemptFailure, {
...baseContext,
errText,
}, 'onAttemptFailure');
const isLastEndpoint = endpointIndex >= endpointCount - 1;
if (input.disableCrossProtocolFallback && !isLastEndpoint) {
finalStatus = response.status;
finalErrText = errText;
finalRawErrText = rawErrText;
break;
}
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
if (shouldDowngrade) {
await runEndpointFlowHook(input.onDowngrade, {
...baseContext,
errText,
}, 'onDowngrade');
continue;
}
finalStatus = response.status;
finalErrText = errText;
finalRawErrText = rawErrText;
break;
}
return {
ok: false,
status: finalStatus || 502,
errText: finalErrText || 'unknown error',
rawErrText: finalRawErrText,
};
}
export type {
BuiltEndpointRequest,
EndpointAttemptContext,
EndpointAttemptSuccessContext,
EndpointRecoverResult,
EndpointFlowResult,
ExecuteEndpointFlowInput,
} from '../../proxy-core/orchestration/endpointFlow.js';
@@ -52,6 +52,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -3,6 +3,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
import { resetCodexHttpSessionQueue } from '../../proxy-core/runtime/codexHttpSessionQueue.js';
import { resetCodexSessionResponseStore } from '../../proxy-core/runtime/codexSessionResponseStore.js';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
@@ -69,6 +70,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -174,6 +176,7 @@ describe('responses proxy codex oauth refresh', () => {
beforeEach(() => {
resetCodexHttpSessionQueue();
resetCodexSessionResponseStore();
config.proxyEmptyContentFailEnabled = false;
config.proxyStickySessionEnabled = originalProxyStickySessionEnabled;
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
@@ -215,6 +218,7 @@ describe('responses proxy codex oauth refresh', () => {
});
selectPreferredChannelMock.mockReturnValue(null);
selectNextChannelMock.mockReturnValue(null);
selectPreferredChannelMock.mockReturnValue(null);
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
accessToken: 'fresh-access-token',
accountId: 33,
@@ -488,6 +492,137 @@ describe('responses proxy codex oauth refresh', () => {
expect(forwardedBody.prompt_cache_key).toBe('codex-cache-123');
});
it('infers previous_response_id for codex tool-output follow-up turns on the same downstream session', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_prev_1',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'tool call issued',
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_prev_2',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'tool result accepted',
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const headers = {
session_id: 'session-http-prev-1',
'user-agent': 'CodexClient/1.0',
};
const firstResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
headers,
payload: {
model: 'gpt-5.2-codex',
input: 'start codex tool flow',
},
});
expect(firstResponse.statusCode).toBe(200);
const secondResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
headers,
payload: {
model: 'gpt-5.2-codex',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: '{"ok":true}',
},
],
},
});
expect(secondResponse.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
const secondBody = JSON.parse(secondOptions.body);
expect(secondBody.previous_response_id).toBe('resp_codex_prev_1');
expect(secondBody.input).toEqual([
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: '{"ok":true}',
},
]);
});
it('drops stale previous_response_id and retries codex responses requests once', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'previous_response_not_found',
code: 'previous_response_not_found',
type: 'invalid_request_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_codex_prev_recovered',
object: 'response',
model: 'gpt-5.2-codex',
status: 'completed',
output_text: 'recovered after dropping stale previous_response_id',
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
session_id: 'session-http-prev-recovery',
},
payload: {
model: 'gpt-5.2-codex',
previous_response_id: 'resp_stale',
input: [
{
id: 'tool_out_retry_1',
type: 'function_call_output',
call_id: 'call_retry_1',
output: '{"retry":true}',
},
],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
const firstBody = JSON.parse(firstOptions.body);
const secondBody = JSON.parse(secondOptions.body);
expect(firstBody.previous_response_id).toBe('resp_stale');
expect(secondBody.previous_response_id).toBeUndefined();
expect(secondBody.input).toEqual(firstBody.input);
});
it('strips generic downstream headers before forwarding codex responses upstream', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_codex_header_filter',
@@ -57,6 +57,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -2,12 +2,14 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import WebSocket, { WebSocketServer } from 'ws';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
import { resetCodexSessionResponseStore } from '../../proxy-core/runtime/codexSessionResponseStore.js';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const selectPreferredChannelMock = vi.fn();
const previewSelectedChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
@@ -21,6 +23,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const trackedClientSockets = new Set<WebSocket>();
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
@@ -35,6 +38,7 @@ vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
selectPreferredChannel: (...args: unknown[]) => selectPreferredChannelMock(...args),
previewSelectedChannel: (...args: unknown[]) => previewSelectedChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
@@ -78,6 +82,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -256,16 +261,26 @@ function createDeferred<T>() {
}
function createClientSocket(baseUrl: string, headers: Record<string, string> = {}) {
return new WebSocket(`${baseUrl}/v1/responses`, {
const socket = new WebSocket(`${baseUrl}/v1/responses`, {
headers: {
Authorization: 'Bearer sk-global-proxy-token',
...headers,
},
});
trackedClientSockets.add(socket);
socket.once('close', () => {
trackedClientSockets.delete(socket);
});
return socket;
}
function createClientSocketForPath(path: string, headers: Record<string, string> = {}) {
return new WebSocket(path, { headers });
const socket = new WebSocket(path, { headers });
trackedClientSockets.add(socket);
socket.once('close', () => {
trackedClientSockets.delete(socket);
});
return socket;
}
describe('responses websocket transport', () => {
@@ -274,6 +289,7 @@ describe('responses websocket transport', () => {
let app: FastifyInstance;
let baseUrl: string;
let upstreamServer: WebSocketServer;
let upstreamSockets: Set<WebSocket>;
let upstreamSiteUrl: string;
let upstreamConnectionCount: number;
let upstreamUpgradeHeaders: Record<string, string>;
@@ -294,7 +310,12 @@ describe('responses websocket transport', () => {
baseUrl = `ws://127.0.0.1:${address.port}`;
upstreamServer = new WebSocketServer({ port: 0 });
upstreamSockets = new Set();
upstreamServer.on('connection', (socket, request) => {
upstreamSockets.add(socket);
socket.once('close', () => {
upstreamSockets.delete(socket);
});
upstreamConnectionCount += 1;
upstreamUpgradeHeaders = Object.fromEntries(
Object.entries(request.headers)
@@ -329,9 +350,11 @@ describe('responses websocket transport', () => {
});
beforeEach(() => {
resetCodexSessionResponseStore();
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
selectPreferredChannelMock.mockReset();
previewSelectedChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
@@ -346,6 +369,7 @@ describe('responses websocket transport', () => {
const selectedChannel = createSelectedChannel();
selectChannelMock.mockReturnValue(selectedChannel);
selectNextChannelMock.mockReturnValue(null);
selectPreferredChannelMock.mockReturnValue(null);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamConnectionCount = 0;
upstreamUpgradeHeaders = {};
@@ -386,8 +410,29 @@ describe('responses websocket transport', () => {
};
});
afterEach(() => {
for (const socket of trackedClientSockets) {
try {
socket.terminate();
} catch {}
}
trackedClientSockets.clear();
});
afterAll(async () => {
(config as any).codexUpstreamWebsocketEnabled = originalCodexUpstreamWebsocketEnabled;
for (const socket of trackedClientSockets) {
try {
socket.terminate();
} catch {}
}
trackedClientSockets.clear();
for (const socket of upstreamSockets || []) {
try {
socket.terminate();
} catch {}
}
upstreamSockets?.clear();
if (rejectedUpgradeServer) {
await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve()));
}
@@ -551,6 +596,143 @@ describe('responses websocket transport', () => {
expect(upstreamUpgradeHeaders['openai-beta']).toContain('responses_websockets=2099-01-01');
});
it('infers previous_response_id for websocket tool-output follow-up turns when the client omits it', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const socket = createClientSocket(baseUrl, {
session_id: 'ws-session-prev-infer',
});
await waitForSocketOpen(socket);
const firstResponsePromise = waitForSocketMessageMatching(
socket,
(message) => message?.type === 'response.completed',
);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
const firstResponse = await firstResponsePromise;
const secondResponsePromise = waitForSocketMessageMatching(
socket,
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_2',
);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [
{
id: 'tool_out_ws_1',
type: 'function_call_output',
call_id: 'call_ws_1',
output: '{"ok":true}',
},
],
}));
await secondResponsePromise;
socket.close();
expect(firstResponse?.response?.id).toBe('resp_upstream_1');
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp_upstream_1',
input: [
{
id: 'tool_out_ws_1',
type: 'function_call_output',
call_id: 'call_ws_1',
output: '{"ok":true}',
},
],
});
});
it('retries websocket turns once without previous_response_id when the upstream reports previous_response_not_found', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket, parsed, requestIndex) => {
if (requestIndex === 1) {
socket.send(JSON.stringify({
type: 'error',
error: {
message: 'previous_response_not_found',
code: 'previous_response_not_found',
type: 'invalid_request_error',
},
}));
return;
}
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp_upstream_recovered',
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
};
const socket = createClientSocket(baseUrl, {
session_id: 'ws-session-prev-recovery',
});
await waitForSocketOpen(socket);
const responsePromise = waitForSocketMessageMatching(
socket,
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_upstream_recovered',
);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
previous_response_id: 'resp_stale_ws',
input: [
{
id: 'tool_out_ws_retry_1',
type: 'function_call_output',
call_id: 'call_ws_retry_1',
output: '{"retry":true}',
},
],
}));
await responsePromise;
socket.close();
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp_stale_ws',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
input: [
{
id: 'tool_out_ws_retry_1',
type: 'function_call_output',
call_id: 'call_ws_retry_1',
output: '{"retry":true}',
},
],
});
expect(upstreamRequests[1]?.previous_response_id).toBeUndefined();
});
it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 426', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: rejectedUpgradeSiteUrl,
+20 -2
View File
@@ -4,6 +4,7 @@ import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
import { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } from '../../proxy-core/runtime/codexWebsocketRuntime.js';
import { buildCodexSessionResponseStoreKey } from '../../proxy-core/runtime/codexSessionResponseStore.js';
import {
authorizeDownstreamToken,
consumeManagedKeyRequest,
@@ -541,13 +542,23 @@ async function handleResponsesWebsocketConnection(
const websocketSessionId = headerValueToTrimmedString(request.headers['session_id'])
|| headerValueToTrimmedString(request.headers['session-id'])
|| randomUUID();
const runtimeSessionKeys = new Set<string>();
let lastRequest: Record<string, unknown> | null = null;
let lastResponseOutput: unknown[] = [];
let selectedChannel: SelectedChannel | null = null;
let messageQueue = Promise.resolve();
socket.once('close', () => {
void codexWebsocketRuntime.closeSession(websocketSessionId);
const sessionKeys = runtimeSessionKeys.size > 0
? Array.from(runtimeSessionKeys)
: [websocketSessionId];
void Promise.all(sessionKeys.map(async (sessionKey) => {
try {
await codexWebsocketRuntime.closeSession(sessionKey);
} catch {
// Ignore close-time cleanup failures after downstream disconnects.
}
}));
});
socket.on('message', (raw) => {
@@ -634,8 +645,15 @@ async function handleResponsesWebsocketConnection(
const requestUrl = `${codexWebsocketChannel.site.url.replace(/\/+$/, '')}${prepared.path}`;
try {
const runtimeResult = await codexWebsocketRuntime.sendRequest({
const websocketRuntimeSessionKey = buildCodexSessionResponseStoreKey({
sessionId: websocketSessionId,
siteId: codexWebsocketChannel.site.id,
accountId: codexWebsocketChannel.account.id,
channelId: codexWebsocketChannel.channel.id,
}) || websocketSessionId;
runtimeSessionKeys.add(websocketRuntimeSessionKey);
const runtimeResult = await codexWebsocketRuntime.sendRequest({
sessionId: websocketRuntimeSessionKey,
requestUrl,
headers: prepared.headers,
body: prepared.body,
+1
View File
@@ -48,6 +48,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
shouldAbortSameSiteEndpointFallback: () => false,
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -8,6 +8,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
}));
import {
buildClaudeCountTokensUpstreamRequest,
buildMinimalJsonHeadersForCompatibility,
buildUpstreamEndpointRequest,
isUnsupportedMediaTypeError,
@@ -188,6 +189,23 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(codexOrder).toEqual(['responses']);
});
it('prefers responses for claude continuation follow-ups that carry orphan tool results', async () => {
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'openai' },
},
'gpt-5.4',
'claude',
'claude-opus-4-6',
{
wantsContinuationAwareResponses: true,
},
);
expect(order).toEqual(['responses', 'messages', 'chat']);
});
it('prefers document-capable endpoints when downstream content contains non-image files', async () => {
const order = await resolveUpstreamEndpointCandidates(
{
@@ -2053,6 +2071,60 @@ describe('buildUpstreamEndpointRequest', () => {
});
});
it('drops responses-style continuation fields before proxying native Claude messages upstream', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'messages',
modelName: 'claude-opus-4-6',
stream: false,
tokenValue: 'sk-test',
sitePlatform: 'claude',
siteUrl: 'https://example.com',
downstreamFormat: 'claude',
openaiBody: {
model: 'ignored',
messages: [{ role: 'user', content: 'ignored' }],
},
claudeOriginalBody: {
model: 'claude-opus-4-6',
max_tokens: 256,
previous_response_id: 'resp_prev_1',
prompt_cache_key: 'cache-key-1',
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(request.body).toEqual({
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
stream: false,
});
});
it('drops responses-style continuation fields before proxying Claude count_tokens upstream', () => {
const request = buildClaudeCountTokensUpstreamRequest({
modelName: 'claude-opus-4-6',
tokenValue: 'sk-test',
sitePlatform: 'claude',
claudeBody: {
model: 'claude-opus-4-6',
max_tokens: 256,
previous_response_id: 'resp_prev_1',
prompt_cache_key: 'cache-key-1',
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(request.body).toMatchObject({
model: 'claude-opus-4-6',
messages: [{ role: 'user' }],
});
expect(request.body).not.toHaveProperty('previous_response_id');
expect(request.body).not.toHaveProperty('prompt_cache_key');
expect(request.body).not.toHaveProperty('max_tokens');
expect(request.body).not.toHaveProperty('maxTokens');
});
it('preserves multimodal OpenAI user content when converting to Responses input', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
+21 -7
View File
@@ -3,6 +3,7 @@ import {
rankConversationFileEndpoints,
type ConversationFileInputSummary,
} from '../../proxy-core/capabilities/conversationFileCapabilities.js';
import type { UpstreamEndpoint } from '../../proxy-core/orchestration/upstreamRequest.js';
import { resolveProviderProfile } from '../../proxy-core/providers/registry.js';
import { config } from '../../config.js';
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
@@ -46,7 +47,7 @@ export {
shouldPreferResponsesAfterLegacyChatError,
};
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
export type { UpstreamEndpoint } from '../../proxy-core/orchestration/upstreamRequest.js';
export type EndpointPreference = DownstreamFormat | 'responses';
type ChannelContext = {
@@ -225,6 +226,15 @@ function extractClaudeBetasFromBody(body: Record<string, unknown>): {
};
}
function stripClaudeMessagesContinuationFields(
body: Record<string, unknown>,
): Record<string, unknown> {
const next = { ...body };
delete next.previous_response_id;
delete next.prompt_cache_key;
return next;
}
function buildAntigravityRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
stream: boolean;
@@ -529,6 +539,7 @@ export async function resolveUpstreamEndpointCandidates(
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
},
): Promise<UpstreamEndpoint[]> {
const sitePlatform = normalizePlatformName(context.site.platform);
@@ -540,6 +551,7 @@ export async function resolveUpstreamEndpointCandidates(
const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel;
const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput;
const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning;
const wantsContinuationAwareResponses = capabilityProfile.wantsContinuationAwareResponses;
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
applyUpstreamEndpointRuntimePreference(candidates, {
siteId: context.site.id,
@@ -587,9 +599,11 @@ export async function resolveUpstreamEndpointCandidates(
})()
: preferred;
const prioritizedPreferredEndpoints: UpstreamEndpoint[] = (
wantsNativeResponsesReasoning
&& preferMessagesForClaudeModel
&& preferredWithCapabilities.includes('responses')
preferredWithCapabilities.includes('responses')
&& (
wantsContinuationAwareResponses
|| (wantsNativeResponsesReasoning && preferMessagesForClaudeModel)
)
)
? [
'responses',
@@ -865,7 +879,7 @@ export function buildUpstreamEndpointRequest(input: {
&& input.forceNormalizeClaudeBody !== true
)
? {
...input.claudeOriginalBody,
...stripClaudeMessagesContinuationFields(input.claudeOriginalBody),
model: input.modelName,
stream: input.stream,
}
@@ -876,7 +890,7 @@ export function buildUpstreamEndpointRequest(input: {
&& input.forceNormalizeClaudeBody === true
)
? sanitizeAnthropicMessagesBody({
...input.claudeOriginalBody,
...stripClaudeMessagesContinuationFields(input.claudeOriginalBody),
model: input.modelName,
stream: input.stream,
})
@@ -1034,7 +1048,7 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
const sitePlatform = normalizePlatformName(input.sitePlatform);
const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders);
const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({
...input.claudeBody,
...stripClaudeMessagesContinuationFields(input.claudeBody),
model: input.modelName,
});
const sanitizedBody = sanitizeAnthropicMessagesBody(bodyWithoutBetas);
+1 -81
View File
@@ -1,81 +1 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function collapseWhitespace(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
function extractJsonErrorMessage(rawText: string): string {
try {
const parsed = JSON.parse(rawText) as unknown;
const root = isRecord(parsed) ? parsed : null;
const error = (root && isRecord(root.error)) ? root.error : root;
if (!error) return '';
const message = typeof error.message === 'string' ? collapseWhitespace(error.message) : '';
if (message) return message;
const code = typeof error.code === 'string' ? collapseWhitespace(error.code) : '';
const type = typeof error.type === 'string' ? collapseWhitespace(error.type) : '';
return [type, code].filter((part) => part.length > 0).join('/');
} catch {
return '';
}
}
function extractHtmlTitle(rawText: string): string {
const match = rawText.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!match?.[1]) return '';
return collapseWhitespace(match[1].replace(/<[^>]+>/g, ''));
}
function extractCloudflareHtmlSummary(rawText: string, status: number): string {
if (!/cloudflare/i.test(rawText)) return '';
const title = extractHtmlTitle(rawText);
const codeMatch = (
title.match(/\b(\d{3,4})\s*:\s*([^\|<]+)/i)
|| rawText.match(/Error code\s*(\d{3,4})/i)
);
const code = codeMatch?.[1] || (status > 0 ? String(status) : '');
const reason = collapseWhitespace(
(typeof codeMatch?.[2] === 'string' ? codeMatch[2] : '')
|| (status >= 500 ? 'origin host error' : 'request blocked')
);
if (code) return `Cloudflare ${code}: ${reason}`;
return `Cloudflare: ${reason}`;
}
function extractHtmlSummary(rawText: string, status: number): string {
if (!/(<!doctype|<html)/i.test(rawText)) return '';
const cloudflareSummary = extractCloudflareHtmlSummary(rawText, status);
if (cloudflareSummary) return cloudflareSummary;
const title = extractHtmlTitle(rawText);
if (title) return title;
const heading = rawText.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1] || '';
return collapseWhitespace(heading.replace(/<[^>]+>/g, ''));
}
export function summarizeUpstreamError(status: number, rawErrorText: string): string {
const statusPrefix = status > 0
? `Upstream returned HTTP ${status}`
: 'Upstream request failed';
const raw = typeof rawErrorText === 'string' ? rawErrorText.trim() : '';
if (!raw) return statusPrefix;
const jsonMessage = extractJsonErrorMessage(raw);
if (jsonMessage) return `${statusPrefix}: ${jsonMessage}`;
const htmlMessage = extractHtmlSummary(raw, status);
if (htmlMessage) return `${statusPrefix}: ${htmlMessage}`;
const compact = collapseWhitespace(raw);
if (!compact) return statusPrefix;
if (compact.length <= 400) return `${statusPrefix}: ${compact}`;
return `${statusPrefix}: ${compact.slice(0, 400)}...(truncated)`;
}
export { summarizeUpstreamError } from '../../proxy-core/orchestration/upstreamRequest.js';
+1 -58
View File
@@ -1,58 +1 @@
function formatUrlOrigin(url: URL): string {
// URL.origin drops credentials; preserve them if present.
const username = url.username ? encodeURIComponent(url.username) : '';
const password = url.password ? encodeURIComponent(url.password) : '';
const auth = username
? `${username}${password ? `:${password}` : ''}@`
: '';
return `${url.protocol}//${auth}${url.host}`;
}
function joinPath(basePath: string, requestPath: string): string {
const base = basePath.replace(/\/+$/, '');
const path = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
if (!base || base === '/') return path || '/';
if (!path || path === '/') return base;
return `${base}${path}`;
}
export function buildUpstreamUrl(siteUrl: string, requestPath: string): string {
const baseRaw = typeof siteUrl === 'string' ? siteUrl.trim() : '';
const pathRaw = typeof requestPath === 'string' ? requestPath.trim() : '';
const fallbackBase = baseRaw.replace(/\/+$/, '');
let path = pathRaw.startsWith('/') ? pathRaw : `/${pathRaw}`;
if (!fallbackBase) return path || '/';
if (!path || path === '/') return fallbackBase;
try {
const parsed = new URL(baseRaw);
const basePath = parsed.pathname.replace(/\/+$/, '');
const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(basePath);
if (baseHasVersionSuffix) {
if (path === '/v1') {
path = '/';
} else if (path.startsWith('/v1/')) {
path = path.slice('/v1'.length) || '/';
}
}
const joinedPath = joinPath(basePath, path);
return `${formatUrlOrigin(parsed)}${joinedPath}${parsed.search}${parsed.hash}`;
} catch {
// Ignore URL parsing errors and fall back to naive join.
const baseHasVersionSuffix = /\/(?:api\/)?v1$/i.test(fallbackBase);
if (baseHasVersionSuffix) {
if (path === '/v1') {
path = '/';
} else if (path.startsWith('/v1/')) {
path = path.slice('/v1'.length) || '/';
}
}
return `${fallbackBase}${path}`;
}
}
export { buildUpstreamUrl } from '../../proxy-core/orchestration/upstreamRequest.js';
+1
View File
@@ -53,6 +53,7 @@ vi.mock('../../services/modelPricingService.js', () => ({
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldAbortSameSiteEndpointFallback: () => false,
shouldRetryProxyRequest: (...args: unknown[]) => shouldRetryProxyRequestMock(...args),
RETRYABLE_TIMEOUT_PATTERNS: [/(request timed out|connection timed out|read timeout|\btimed out\b)/i],
}));
@@ -301,6 +301,21 @@ export function getRunningTaskByDedupeKey(key: string): BackgroundTask | null {
return task;
}
export async function waitForBackgroundTaskCompletion(taskId: string, pollIntervalMs = 25): Promise<BackgroundTask | null> {
const safePollIntervalMs = Math.max(5, Math.trunc(pollIntervalMs || 0));
while (true) {
const task = getBackgroundTask(taskId);
if (!task) return null;
if (task.status !== 'pending' && task.status !== 'running') {
return task;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, safePollIntervalMs);
timer.unref?.();
});
}
}
export function summarizeCheckinResults(results: Array<{ result?: any }>): { total: number; success: number; skipped: number; failed: number } {
const summary = { total: results.length, success: 0, skipped: 0, failed: 0 };
for (const item of results) {
@@ -0,0 +1,348 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
const probeRuntimeModelMock = vi.fn();
vi.mock('./runtimeModelProbe.js', () => ({
probeRuntimeModel: (...args: unknown[]) => probeRuntimeModelMock(...args),
}));
type DbModule = typeof import('../db/index.js');
type RecoveryModule = typeof import('./channelRecoveryProbeService.js');
type CoordinatorModule = typeof import('./proxyChannelCoordinator.js');
type ConfigModule = typeof import('../config.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
describe('channelRecoveryProbeService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let runChannelRecoveryProbeSweep: RecoveryModule['runChannelRecoveryProbeSweep'];
let resetChannelRecoveryProbeState: RecoveryModule['resetChannelRecoveryProbeState'];
let proxyChannelCoordinator: CoordinatorModule['proxyChannelCoordinator'];
let resetProxyChannelCoordinatorState: CoordinatorModule['resetProxyChannelCoordinatorState'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let config: ConfigModule['config'];
let dataDir = '';
let originalDataDir: string | undefined;
let originalConcurrencyLimit = 0;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-channel-recovery-probe-'));
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const recoveryModule = await import('./channelRecoveryProbeService.js');
const coordinatorModule = await import('./proxyChannelCoordinator.js');
const configModule = await import('../config.js');
const tokenRouterModule = await import('./tokenRouter.js');
db = dbModule.db;
schema = dbModule.schema;
runChannelRecoveryProbeSweep = recoveryModule.runChannelRecoveryProbeSweep;
resetChannelRecoveryProbeState = recoveryModule.resetChannelRecoveryProbeState;
proxyChannelCoordinator = coordinatorModule.proxyChannelCoordinator;
resetProxyChannelCoordinatorState = coordinatorModule.resetProxyChannelCoordinatorState;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
config = configModule.config;
originalConcurrencyLimit = config.proxySessionChannelConcurrencyLimit;
});
beforeEach(async () => {
probeRuntimeModelMock.mockReset();
probeRuntimeModelMock.mockResolvedValue({
status: 'supported',
latencyMs: 320,
reason: 'probe succeeded',
});
config.proxySessionChannelConcurrencyLimit = 1;
resetChannelRecoveryProbeState();
resetProxyChannelCoordinatorState();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
config.proxySessionChannelConcurrencyLimit = originalConcurrencyLimit;
resetChannelRecoveryProbeState();
resetProxyChannelCoordinatorState();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
rmSync(dataDir, { recursive: true, force: true });
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
});
it('clears cooldown markers when a background probe succeeds', async () => {
const site = await db.insert(schema.sites).values({
name: 'recovery-site',
url: 'https://recovery-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'recovery-user',
accessToken: 'access-recovery',
apiToken: 'sk-recovery',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-recovery-token',
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
enabled: true,
cooldownUntil: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
lastFailAt: new Date().toISOString(),
consecutiveFailCount: 2,
cooldownLevel: 1,
}).returning().get();
await runChannelRecoveryProbeSweep();
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(1);
expect(probeRuntimeModelMock.mock.calls[0]?.[0]).toMatchObject({
modelName: 'gpt-5.4',
tokenValue: 'sk-recovery-token',
});
const refreshed = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.id, channel.id))
.get();
expect(refreshed?.cooldownUntil).toBeNull();
expect(refreshed?.lastFailAt).toBeNull();
expect(refreshed?.consecutiveFailCount).toBe(0);
expect(refreshed?.cooldownLevel).toBe(0);
});
it('also probes active leased channels in the background', async () => {
const site = await db.insert(schema.sites).values({
name: 'active-site',
url: 'https://active-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'active-user',
accessToken: 'access-active',
apiToken: 'sk-active',
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
}),
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-active-token',
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
enabled: true,
}).returning().get();
const lease = await proxyChannelCoordinator.acquireChannelLease({
channelId: channel.id,
accountExtraConfig: account.extraConfig,
});
expect(lease.status).toBe('acquired');
if (lease.status !== 'acquired') return;
await runChannelRecoveryProbeSweep();
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(1);
expect(probeRuntimeModelMock.mock.calls[0]?.[0]).toMatchObject({
modelName: 'gpt-5.2',
tokenValue: 'sk-active-token',
});
lease.lease.release();
});
it('skips provider-directed quota cooldown channels during recovery sweeps', async () => {
const site = await db.insert(schema.sites).values({
name: 'quota-site',
url: 'https://quota-site.example.com',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'quota-user',
accessToken: 'access-quota',
apiToken: 'sk-quota',
status: 'active',
}).returning().get();
const quotaToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'quota-token',
token: 'sk-quota-token',
enabled: true,
isDefault: false,
}).returning().get();
const retryToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'retry-token',
token: 'sk-retry-token',
enabled: true,
isDefault: false,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: route.id,
accountId: account.id,
tokenId: quotaToken.id,
enabled: true,
cooldownUntil: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
lastFailAt: new Date().toISOString(),
failCount: 0,
consecutiveFailCount: 0,
cooldownLevel: 0,
},
{
routeId: route.id,
accountId: account.id,
tokenId: retryToken.id,
enabled: true,
cooldownUntil: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
lastFailAt: new Date().toISOString(),
failCount: 2,
consecutiveFailCount: 0,
cooldownLevel: 0,
},
]).run();
await runChannelRecoveryProbeSweep();
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(1);
expect(probeRuntimeModelMock.mock.calls[0]?.[0]).toMatchObject({
tokenValue: 'sk-retry-token',
modelName: 'gpt-5.4',
});
});
it('prioritizes never-probed active channels before reprobing recently started ones', async () => {
const site = await db.insert(schema.sites).values({
name: 'priority-site',
url: 'https://priority-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const leases: Array<{ release: () => void }> = [];
try {
for (let index = 1; index <= 5; index += 1) {
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: `priority-user-${index}`,
accessToken: `access-priority-${index}`,
apiToken: `sk-priority-${index}`,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
}),
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: `token-${index}`,
token: `sk-priority-token-${index}`,
enabled: true,
isDefault: true,
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: `gpt-5.4-${index}`,
enabled: true,
}).returning().get();
const channel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
enabled: true,
}).returning().get();
const lease = await proxyChannelCoordinator.acquireChannelLease({
channelId: channel.id,
accountExtraConfig: account.extraConfig,
});
expect(lease.status).toBe('acquired');
if (lease.status === 'acquired') {
leases.push(lease.lease);
}
}
const startedAt = Date.UTC(2026, 3, 1, 0, 0, 0);
await runChannelRecoveryProbeSweep(startedAt);
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(4);
expect(probeRuntimeModelMock.mock.calls.map((call) => call[0]?.tokenValue)).not.toContain('sk-priority-token-5');
probeRuntimeModelMock.mockClear();
await runChannelRecoveryProbeSweep(startedAt + 5 * 60 * 1000);
expect(probeRuntimeModelMock).toHaveBeenCalledTimes(4);
const secondSweepTokens = probeRuntimeModelMock.mock.calls.map((call) => call[0]?.tokenValue);
expect(secondSweepTokens).toContain('sk-priority-token-5');
} finally {
for (const lease of leases) {
lease.release();
}
}
});
});
@@ -0,0 +1,299 @@
import { and, eq, gt, inArray, isNotNull } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { isUsableAccountToken } from './accountTokenService.js';
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
import { proxyChannelCoordinator } from './proxyChannelCoordinator.js';
import { probeRuntimeModel } from './runtimeModelProbe.js';
import { tokenRouter } from './tokenRouter.js';
import { isExactTokenRouteModelPattern } from '../../shared/tokenRoutePatterns.js';
type RecoveryProbeSource = 'cooldown' | 'active';
type RecoveryProbeCandidate = {
source: RecoveryProbeSource;
channelId: number;
modelName: string;
tokenValue: string;
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
};
const CHANNEL_RECOVERY_SWEEP_INTERVAL_MS = 30_000;
const CHANNEL_RECOVERY_PROBE_TIMEOUT_MS = 12_000;
// Keep recovery probes conservative so they do not look like bulk health checks to upstream providers.
const CHANNEL_RECOVERY_PROBE_CONCURRENCY = 1;
const CHANNEL_RECOVERY_MAX_BATCH = 4;
const CHANNEL_RECOVERY_COOLDOWN_RECHECK_MS = 30_000;
const CHANNEL_RECOVERY_ACTIVE_RECHECK_MS = 5 * 60_000;
let recoveryProbeSchedulerTimer: ReturnType<typeof setInterval> | null = null;
let recoveryProbeSweepInFlight: Promise<void> | null = null;
const recoveryProbeInFlightKeys = new Set<string>();
const recoveryProbeLastStartedAtByKey = new Map<string, number>();
function shouldUnrefTimer(timer: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>) {
if (typeof (timer as { unref?: () => void }).unref === 'function') {
(timer as { unref: () => void }).unref();
}
}
function buildRecoveryProbeKey(channelId: number, modelName: string): string {
return `${channelId}:${String(modelName || '').trim().toLowerCase()}`;
}
function resolveRecoveryProbeWindowMs(source: RecoveryProbeSource): number {
return source === 'cooldown'
? CHANNEL_RECOVERY_COOLDOWN_RECHECK_MS
: CHANNEL_RECOVERY_ACTIVE_RECHECK_MS;
}
function resolveProbeModelName(row: {
route_channels: typeof schema.routeChannels.$inferSelect;
token_routes: typeof schema.tokenRoutes.$inferSelect;
}): string {
const sourceModel = String(row.route_channels.sourceModel || '').trim();
if (sourceModel) return sourceModel;
const routeModelPattern = String(row.token_routes.modelPattern || '').trim();
return isExactTokenRouteModelPattern(routeModelPattern) ? routeModelPattern : '';
}
function resolveProbeTokenValue(row: {
route_channels: typeof schema.routeChannels.$inferSelect;
accounts: typeof schema.accounts.$inferSelect;
account_tokens: typeof schema.accountTokens.$inferSelect | null;
}): string | null {
if (typeof row.route_channels.tokenId === 'number' && row.route_channels.tokenId > 0) {
if (!row.account_tokens || !isUsableAccountToken(row.account_tokens)) return null;
const tokenValue = String(row.account_tokens.token || '').trim();
return tokenValue || null;
}
if (getOauthInfoFromAccount(row.accounts)) {
const accessToken = String(row.accounts.accessToken || '').trim();
return accessToken || null;
}
const fallbackApiToken = String(row.accounts.apiToken || '').trim();
return fallbackApiToken || null;
}
function isProviderDirectedCooldown(row: {
route_channels: typeof schema.routeChannels.$inferSelect;
}): boolean {
return !!row.route_channels.cooldownUntil
&& (row.route_channels.failCount ?? 0) <= 0
&& (row.route_channels.consecutiveFailCount ?? 0) <= 0
&& (row.route_channels.cooldownLevel ?? 0) <= 0;
}
async function mapWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
): Promise<void> {
const safeConcurrency = Math.max(1, Math.min(items.length || 1, Math.trunc(concurrency || 1)));
let nextIndex = 0;
const runWorker = async () => {
while (true) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= items.length) return;
await worker(items[currentIndex] as T, currentIndex);
}
};
await Promise.all(Array.from({ length: safeConcurrency }, () => runWorker()));
}
async function loadCoolingProbeCandidates(nowIso: string): Promise<RecoveryProbeCandidate[]> {
const rows = await db.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(and(
eq(schema.routeChannels.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
isNotNull(schema.routeChannels.cooldownUntil),
gt(schema.routeChannels.cooldownUntil, nowIso),
))
.all();
return rows.flatMap((row) => {
if (isProviderDirectedCooldown(row)) return [];
const modelName = resolveProbeModelName(row);
const tokenValue = resolveProbeTokenValue(row);
if (!modelName || !tokenValue) return [];
return [{
source: 'cooldown' as const,
channelId: row.route_channels.id,
modelName,
tokenValue,
account: row.accounts,
site: row.sites,
}];
});
}
async function loadActiveProbeCandidates(activeChannelIds: number[]): Promise<RecoveryProbeCandidate[]> {
if (activeChannelIds.length <= 0) return [];
const rows = await db.select()
.from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(and(
eq(schema.routeChannels.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
inArray(schema.routeChannels.id, activeChannelIds),
))
.all();
return rows.flatMap((row) => {
const modelName = resolveProbeModelName(row);
const tokenValue = resolveProbeTokenValue(row);
if (!modelName || !tokenValue) return [];
return [{
source: 'active' as const,
channelId: row.route_channels.id,
modelName,
tokenValue,
account: row.accounts,
site: row.sites,
}];
});
}
function mergeRecoveryProbeCandidates(candidates: RecoveryProbeCandidate[]): RecoveryProbeCandidate[] {
const merged = new Map<number, RecoveryProbeCandidate>();
for (const candidate of candidates) {
const existing = merged.get(candidate.channelId);
if (!existing || (existing.source === 'active' && candidate.source === 'cooldown')) {
merged.set(candidate.channelId, candidate);
}
}
return Array.from(merged.values());
}
function shouldProbeCandidate(candidate: RecoveryProbeCandidate, nowMs: number): boolean {
const key = buildRecoveryProbeKey(candidate.channelId, candidate.modelName);
if (recoveryProbeInFlightKeys.has(key)) return false;
const lastStartedAt = recoveryProbeLastStartedAtByKey.get(key) ?? 0;
return (nowMs - lastStartedAt) >= resolveRecoveryProbeWindowMs(candidate.source);
}
function compareRecoveryProbeCandidatePriority(left: RecoveryProbeCandidate, right: RecoveryProbeCandidate): number {
const leftKey = buildRecoveryProbeKey(left.channelId, left.modelName);
const rightKey = buildRecoveryProbeKey(right.channelId, right.modelName);
const leftLastStartedAt = recoveryProbeLastStartedAtByKey.get(leftKey);
const rightLastStartedAt = recoveryProbeLastStartedAtByKey.get(rightKey);
if (leftLastStartedAt == null && rightLastStartedAt == null) {
return left.channelId - right.channelId;
}
if (leftLastStartedAt == null) return -1;
if (rightLastStartedAt == null) return 1;
if (leftLastStartedAt !== rightLastStartedAt) {
return leftLastStartedAt - rightLastStartedAt;
}
return left.channelId - right.channelId;
}
async function runRecoveryProbeCandidate(candidate: RecoveryProbeCandidate, nowMs: number): Promise<void> {
const key = buildRecoveryProbeKey(candidate.channelId, candidate.modelName);
recoveryProbeInFlightKeys.add(key);
recoveryProbeLastStartedAtByKey.set(key, nowMs);
try {
const result = await probeRuntimeModel({
site: candidate.site,
account: candidate.account,
modelName: candidate.modelName,
tokenValue: candidate.tokenValue,
timeoutMs: CHANNEL_RECOVERY_PROBE_TIMEOUT_MS,
});
if (result.status === 'supported') {
await tokenRouter.recordProbeSuccess(
candidate.channelId,
result.latencyMs ?? 0,
candidate.modelName,
);
}
} catch (error) {
console.warn(`[channel-recovery-probe] channel ${candidate.channelId} probe failed`, error);
} finally {
recoveryProbeInFlightKeys.delete(key);
}
}
export async function runChannelRecoveryProbeSweep(nowMs = Date.now()): Promise<void> {
if (recoveryProbeSweepInFlight) {
await recoveryProbeSweepInFlight;
return;
}
recoveryProbeSweepInFlight = (async () => {
const nowIso = new Date(nowMs).toISOString();
const activeChannelIds = proxyChannelCoordinator.getActiveChannelIds();
const [coolingCandidates, activeCandidates] = await Promise.all([
loadCoolingProbeCandidates(nowIso),
loadActiveProbeCandidates(activeChannelIds),
]);
const merged = mergeRecoveryProbeCandidates([
...coolingCandidates,
...activeCandidates,
]);
const dueCandidates = merged
.filter((candidate) => shouldProbeCandidate(candidate, nowMs))
.sort(compareRecoveryProbeCandidatePriority)
.slice(0, CHANNEL_RECOVERY_MAX_BATCH);
if (dueCandidates.length <= 0) return;
await mapWithConcurrency(
dueCandidates,
CHANNEL_RECOVERY_PROBE_CONCURRENCY,
async (candidate) => runRecoveryProbeCandidate(candidate, nowMs),
);
})().finally(() => {
recoveryProbeSweepInFlight = null;
});
await recoveryProbeSweepInFlight;
}
export function startChannelRecoveryProbeScheduler(intervalMs = CHANNEL_RECOVERY_SWEEP_INTERVAL_MS) {
stopChannelRecoveryProbeScheduler();
const safeIntervalMs = Math.max(10_000, Math.trunc(intervalMs || 0));
recoveryProbeSchedulerTimer = setInterval(() => {
void runChannelRecoveryProbeSweep().catch((error) => {
console.warn('[channel-recovery-probe] background sweep failed', error);
});
}, safeIntervalMs);
shouldUnrefTimer(recoveryProbeSchedulerTimer);
void runChannelRecoveryProbeSweep().catch((error) => {
console.warn('[channel-recovery-probe] initial sweep failed', error);
});
return {
enabled: true,
intervalMs: safeIntervalMs,
};
}
export function stopChannelRecoveryProbeScheduler() {
if (recoveryProbeSchedulerTimer) {
clearInterval(recoveryProbeSchedulerTimer);
recoveryProbeSchedulerTimer = null;
}
}
export function resetChannelRecoveryProbeState() {
stopChannelRecoveryProbeScheduler();
recoveryProbeSweepInFlight = null;
recoveryProbeInFlightKeys.clear();
recoveryProbeLastStartedAtByKey.clear();
}
@@ -0,0 +1,507 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
const resolveUpstreamEndpointCandidatesMock = vi.fn();
const buildUpstreamEndpointRequestMock = vi.fn();
const dispatchRuntimeRequestMock = vi.fn();
const resolveChannelProxyUrlMock = vi.fn();
const withSiteRecordProxyRequestInitMock = vi.fn();
const rebuildRoutesOnlyMock = vi.fn();
vi.mock('./upstreamEndpointRuntime.js', () => ({
resolveUpstreamEndpointCandidates: (...args: unknown[]) => resolveUpstreamEndpointCandidatesMock(...args),
buildUpstreamEndpointRequest: (...args: unknown[]) => buildUpstreamEndpointRequestMock(...args),
}));
vi.mock('./runtimeDispatch.js', () => ({
dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args),
}));
vi.mock('./siteProxy.js', () => ({
resolveChannelProxyUrl: (...args: unknown[]) => resolveChannelProxyUrlMock(...args),
withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args),
}));
vi.mock('./routeRefreshWorkflow.js', () => ({
rebuildRoutesOnly: (...args: unknown[]) => rebuildRoutesOnlyMock(...args),
}));
type DbModule = typeof import('../db/index.js');
type ProbeModule = typeof import('./modelAvailabilityProbeService.js');
describe('modelAvailabilityProbeService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let executeModelAvailabilityProbe: ProbeModule['executeModelAvailabilityProbe'];
let resetProbeExecutionState: ProbeModule['__resetModelAvailabilityProbeExecutionStateForTests'];
let dataDir = '';
let originalDataDir: string | undefined;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-model-probe-'));
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const probeModule = await import('./modelAvailabilityProbeService.js');
db = dbModule.db;
schema = dbModule.schema;
executeModelAvailabilityProbe = probeModule.executeModelAvailabilityProbe;
resetProbeExecutionState = probeModule.__resetModelAvailabilityProbeExecutionStateForTests;
});
beforeEach(async () => {
resolveUpstreamEndpointCandidatesMock.mockReset();
buildUpstreamEndpointRequestMock.mockReset();
dispatchRuntimeRequestMock.mockReset();
resolveChannelProxyUrlMock.mockReset();
withSiteRecordProxyRequestInitMock.mockReset();
rebuildRoutesOnlyMock.mockReset();
rebuildRoutesOnlyMock.mockResolvedValue(undefined);
resetProbeExecutionState();
resolveUpstreamEndpointCandidatesMock.mockResolvedValue(['chat']);
buildUpstreamEndpointRequestMock.mockImplementation((input: { modelName?: string; endpoint?: string }) => ({
path: '/v1/chat/completions',
headers: {
'content-type': 'application/json',
},
body: {
model: input.modelName || 'unknown-model',
endpoint: input.endpoint || 'chat',
},
runtime: {
executor: 'default',
modelName: input.modelName || 'unknown-model',
stream: false,
},
}));
dispatchRuntimeRequestMock.mockImplementation(async (input: { request?: { body?: { model?: string } } }) => {
const modelName = String(input?.request?.body?.model || '');
if (modelName === 'gpt-ghost') {
return new Response(JSON.stringify({
error: {
message: 'The model `gpt-ghost` does not exist',
},
}), {
status: 404,
headers: {
'content-type': 'application/json',
},
});
}
return new Response(JSON.stringify({
id: 'resp_probe',
model: modelName,
choices: [
{
message: {
role: 'assistant',
content: 'OK',
},
},
],
}), {
status: 200,
headers: {
'content-type': 'application/json',
},
});
});
resolveChannelProxyUrlMock.mockReturnValue(null);
withSiteRecordProxyRequestInitMock.mockImplementation((_site: unknown, init: unknown) => init);
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
});
it('marks supported rows true and unsupported rows false after probing', async () => {
const site = await db.insert(schema.sites).values({
name: 'probe-site',
url: 'https://probe.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'probe-user',
accessToken: '',
apiToken: 'sk-account-probe',
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-token-probe',
enabled: true,
isDefault: true,
valueStatus: 'ready',
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-real',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
},
]).run();
await db.insert(schema.tokenModelAvailability).values([
{
tokenId: token.id,
modelName: 'gpt-ghost',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
},
]).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
expect(result.summary).toMatchObject({
totalAccounts: 1,
scanned: 2,
supported: 1,
unsupported: 1,
inconclusive: 0,
skippedModels: 0,
updatedRows: 2,
rebuiltRoutes: false,
});
const accountRows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(accountRows).toHaveLength(1);
expect(accountRows[0]?.available).toBe(true);
const tokenRows = await db.select().from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]?.available).toBe(false);
});
it('skips probing obvious non-conversation models', async () => {
const site = await db.insert(schema.sites).values({
name: 'probe-site',
url: 'https://probe.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'probe-user',
accessToken: '',
apiToken: 'sk-account-probe',
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'text-embedding-3-large',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
},
]).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
expect(result.summary).toMatchObject({
totalAccounts: 1,
scanned: 1,
supported: 0,
unsupported: 0,
inconclusive: 0,
skippedModels: 1,
updatedRows: 0,
});
expect(dispatchRuntimeRequestMock).not.toHaveBeenCalled();
});
it('does not mark a model unavailable when probe result is inconclusive', async () => {
dispatchRuntimeRequestMock.mockImplementation(async (input: { request?: { body?: { model?: string } } }) => {
const modelName = String(input?.request?.body?.model || '');
if (modelName === 'gpt-flaky') {
return new Response(JSON.stringify({
error: {
message: 'upstream temporary failure',
},
}), {
status: 502,
headers: {
'content-type': 'application/json',
},
});
}
return new Response(JSON.stringify({
id: 'resp_probe',
model: modelName,
choices: [
{
message: {
role: 'assistant',
content: 'OK',
},
},
],
}), {
status: 200,
headers: {
'content-type': 'application/json',
},
});
});
const site = await db.insert(schema.sites).values({
name: 'probe-site',
url: 'https://probe.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'probe-user',
accessToken: '',
apiToken: 'sk-account-probe',
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
}).returning().get();
const checkedAt = '2026-03-20T00:00:00.000Z';
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-flaky',
available: true,
checkedAt,
},
]).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
expect(result.summary).toMatchObject({
totalAccounts: 1,
scanned: 1,
supported: 0,
unsupported: 0,
inconclusive: 1,
skippedModels: 0,
updatedRows: 0,
});
const accountRows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id))
.all();
expect(accountRows).toHaveLength(1);
expect(accountRows[0]?.available).toBe(true);
expect(accountRows[0]?.checkedAt).toBe(checkedAt);
});
it('does not rebuild routes when probe only refreshes checkedAt and latency', async () => {
const site = await db.insert(schema.sites).values({
name: 'stable-site',
url: 'https://stable.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'stable-user',
accessToken: '',
apiToken: 'sk-stable',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-stable',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
latencyMs: 123,
}).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: true,
});
expect(result.summary.updatedRows).toBe(1);
expect(result.summary.rebuiltRoutes).toBe(false);
expect(rebuildRoutesOnlyMock).not.toHaveBeenCalled();
});
it('rebuilds routes when probe flips availability state', async () => {
const site = await db.insert(schema.sites).values({
name: 'changed-site',
url: 'https://changed.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'changed-user',
accessToken: '',
apiToken: 'sk-changed',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-changed',
available: false,
checkedAt: '2026-03-20T00:00:00.000Z',
}).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: true,
});
expect(result.summary.updatedRows).toBe(1);
expect(result.summary.rebuiltRoutes).toBe(true);
expect(rebuildRoutesOnlyMock).toHaveBeenCalledTimes(1);
});
it('does not count inactive accounts in probe results', async () => {
const site = await db.insert(schema.sites).values({
name: 'inactive-site',
url: 'https://inactive.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'inactive-user',
accessToken: '',
apiToken: 'sk-inactive',
status: 'disabled',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-inactive',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
}).run();
const result = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
expect(result.summary.totalAccounts).toBe(0);
expect(result.results).toEqual([]);
expect(dispatchRuntimeRequestMock).not.toHaveBeenCalled();
});
it('skips an account when another probe already holds the execution lease', async () => {
const site = await db.insert(schema.sites).values({
name: 'leased-site',
url: 'https://leased.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'leased-user',
accessToken: '',
apiToken: 'sk-leased',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-leased',
available: true,
checkedAt: '2026-03-20T00:00:00.000Z',
}).run();
let releaseFirstProbe: (() => void) | null = null;
const firstProbeStarted = new Promise<void>((resolve) => {
dispatchRuntimeRequestMock.mockImplementationOnce(async () => {
resolve();
await new Promise<void>((release) => {
releaseFirstProbe = release;
});
return new Response(JSON.stringify({
id: 'resp_probe',
choices: [{ message: { role: 'assistant', content: 'OK' } }],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
});
const firstProbe = executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
await firstProbeStarted;
const overlappingProbe = await executeModelAvailabilityProbe({
accountId: account.id,
rebuildRoutes: false,
});
releaseFirstProbe?.();
await firstProbe;
expect(overlappingProbe.summary).toMatchObject({
totalAccounts: 1,
skipped: 1,
scanned: 0,
});
expect(overlappingProbe.results[0]).toMatchObject({
accountId: account.id,
siteId: site.id,
status: 'skipped',
message: 'model availability probe already running for account',
});
});
});
@@ -0,0 +1,447 @@
import { and, asc, eq } from 'drizzle-orm';
import { config } from '../config.js';
import { db, schema } from '../db/index.js';
import { startBackgroundTask } from './backgroundTaskService.js';
import { isUsableAccountToken, ACCOUNT_TOKEN_VALUE_STATUS_READY } from './accountTokenService.js';
import { probeRuntimeModel } from './runtimeModelProbe.js';
import * as routeRefreshWorkflow from './routeRefreshWorkflow.js';
type ProbeStatus = 'supported' | 'unsupported' | 'inconclusive' | 'skipped';
type ProbeAccountTarget = {
kind: 'account';
rowId: number;
modelName: string;
lastKnownAvailable: boolean;
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
};
type ProbeTokenTarget = {
kind: 'token';
rowId: number;
tokenId: number;
modelName: string;
tokenValue: string;
lastKnownAvailable: boolean;
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
};
type ProbeTarget = ProbeAccountTarget | ProbeTokenTarget;
type ProbeAccountContext = {
account: typeof schema.accounts.$inferSelect;
site: typeof schema.sites.$inferSelect;
};
export type ModelAvailabilityProbeAccountResult = {
accountId: number;
siteId: number;
status: 'success' | 'failed' | 'skipped';
scanned: number;
supported: number;
unsupported: number;
inconclusive: number;
skipped: number;
updatedRows: number;
message: string;
};
export type ModelAvailabilityProbeExecutionResult = {
results: ModelAvailabilityProbeAccountResult[];
summary: {
totalAccounts: number;
success: number;
failed: number;
skipped: number;
scanned: number;
supported: number;
unsupported: number;
inconclusive: number;
skippedModels: number;
updatedRows: number;
rebuiltRoutes: boolean;
};
};
let probeSchedulerTimer: ReturnType<typeof setInterval> | null = null;
const probeAccountLeases = new Set<number>();
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const safeConcurrency = Math.max(1, Math.min(items.length || 1, Math.trunc(concurrency || 1)));
const results = new Array<R>(items.length);
let nextIndex = 0;
const runWorker = async () => {
while (true) {
const currentIndex = nextIndex;
nextIndex += 1;
if (currentIndex >= items.length) return;
results[currentIndex] = await worker(items[currentIndex] as T, currentIndex);
}
};
await Promise.all(Array.from({ length: safeConcurrency }, () => runWorker()));
return results;
}
async function probeSingleTarget(target: ProbeTarget): Promise<{
status: ProbeStatus;
latencyMs: number | null;
reason: string;
}> {
return await probeRuntimeModel({
site: target.site,
account: target.account,
modelName: target.modelName,
timeoutMs: config.modelAvailabilityProbeTimeoutMs,
tokenValue: target.kind === 'token' ? target.tokenValue : undefined,
});
}
async function updateProbeRow(target: ProbeTarget, status: ProbeStatus, latencyMs: number | null): Promise<{
touched: boolean;
availabilityChanged: boolean;
}> {
if (status === 'inconclusive' || status === 'skipped') {
return {
touched: false,
availabilityChanged: false,
};
}
const nextAvailable = status === 'supported';
const patch = {
available: nextAvailable,
latencyMs,
checkedAt: new Date().toISOString(),
};
if (target.kind === 'account') {
await db.update(schema.modelAvailability)
.set(patch)
.where(eq(schema.modelAvailability.id, target.rowId))
.run();
return {
touched: true,
availabilityChanged: target.lastKnownAvailable !== nextAvailable,
};
}
await db.update(schema.tokenModelAvailability)
.set(patch)
.where(eq(schema.tokenModelAvailability.id, target.rowId))
.run();
return {
touched: true,
availabilityChanged: target.lastKnownAvailable !== nextAvailable,
};
}
async function loadActiveProbeAccountContext(accountId: number): Promise<ProbeAccountContext | null> {
const accountRow = await db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!accountRow) return null;
if ((accountRow.accounts.status || 'active') !== 'active') return null;
if ((accountRow.sites.status || 'active') !== 'active') return null;
return {
account: accountRow.accounts,
site: accountRow.sites,
};
}
async function loadProbeTargetsForAccount(context: ProbeAccountContext): Promise<ProbeTarget[]> {
const targets: ProbeTarget[] = [];
const accountModels = await db.select()
.from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, context.account.id))
.orderBy(asc(schema.modelAvailability.checkedAt))
.all();
for (const row of accountModels) {
if (row.isManual) continue;
targets.push({
kind: 'account',
rowId: row.id,
modelName: row.modelName,
lastKnownAvailable: !!row.available,
account: context.account,
site: context.site,
});
}
const tokenRows = await db.select()
.from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.where(and(
eq(schema.accountTokens.accountId, context.account.id),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.orderBy(asc(schema.tokenModelAvailability.checkedAt))
.all();
for (const row of tokenRows) {
if (!isUsableAccountToken(row.account_tokens)) continue;
const tokenValue = String(row.account_tokens.token || '').trim();
if (!tokenValue) continue;
targets.push({
kind: 'token',
rowId: row.token_model_availability.id,
tokenId: row.account_tokens.id,
modelName: row.token_model_availability.modelName,
tokenValue,
lastKnownAvailable: !!row.token_model_availability.available,
account: context.account,
site: context.site,
});
}
return targets;
}
function tryAcquireProbeAccountLease(accountId: number): boolean {
if (!Number.isFinite(accountId) || accountId <= 0) return false;
if (probeAccountLeases.has(accountId)) return false;
probeAccountLeases.add(accountId);
return true;
}
function releaseProbeAccountLease(accountId: number): void {
probeAccountLeases.delete(accountId);
}
function buildSkippedProbeAccountResult(input: {
accountId: number;
siteId: number;
message: string;
}): ModelAvailabilityProbeAccountResult {
return {
accountId: input.accountId,
siteId: input.siteId,
status: 'skipped',
scanned: 0,
supported: 0,
unsupported: 0,
inconclusive: 0,
skipped: 0,
updatedRows: 0,
message: input.message,
};
}
function summarizeProbeResults(results: ModelAvailabilityProbeAccountResult[], rebuiltRoutes: boolean): ModelAvailabilityProbeExecutionResult {
return {
results,
summary: {
totalAccounts: results.length,
success: results.filter((item) => item.status === 'success').length,
failed: results.filter((item) => item.status === 'failed').length,
skipped: results.filter((item) => item.status === 'skipped').length,
scanned: results.reduce((sum, item) => sum + item.scanned, 0),
supported: results.reduce((sum, item) => sum + item.supported, 0),
unsupported: results.reduce((sum, item) => sum + item.unsupported, 0),
inconclusive: results.reduce((sum, item) => sum + item.inconclusive, 0),
skippedModels: results.reduce((sum, item) => sum + item.skipped, 0),
updatedRows: results.reduce((sum, item) => sum + item.updatedRows, 0),
rebuiltRoutes,
},
};
}
export async function executeModelAvailabilityProbe(input: {
accountId?: number;
rebuildRoutes?: boolean;
} = {}): Promise<ModelAvailabilityProbeExecutionResult> {
const accountIds = input.accountId
? [input.accountId]
: (await db.select({ id: schema.accounts.id })
.from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all()).map((row) => row.id);
const results: ModelAvailabilityProbeAccountResult[] = [];
let shouldRebuildRoutes = false;
for (const accountId of accountIds) {
const context = await loadActiveProbeAccountContext(accountId);
if (!context) {
continue;
}
if (!tryAcquireProbeAccountLease(accountId)) {
results.push(buildSkippedProbeAccountResult({
accountId,
siteId: context.site.id,
message: 'model availability probe already running for account',
}));
continue;
}
try {
const targets = await loadProbeTargetsForAccount(context);
if (targets.length <= 0) {
results.push(buildSkippedProbeAccountResult({
accountId,
siteId: context.site.id,
message: 'no discovered models to probe',
}));
continue;
}
let supported = 0;
let unsupported = 0;
let inconclusive = 0;
let skipped = 0;
let updatedRows = 0;
let failed = false;
const probeOutcomes = await mapWithConcurrency(
targets,
config.modelAvailabilityProbeConcurrency,
async (target) => {
try {
const probe = await probeSingleTarget(target);
const update = await updateProbeRow(target, probe.status, probe.latencyMs);
return {
target,
probe,
touched: update.touched,
availabilityChanged: update.availabilityChanged,
failed: false,
};
} catch (error) {
console.warn(`[model-probe] account ${accountId} model ${target.modelName} probe failed`, error);
return {
target,
probe: {
status: 'inconclusive' as const,
latencyMs: null,
reason: error instanceof Error ? error.message : 'probe failed',
},
touched: false,
availabilityChanged: false,
failed: true,
};
}
},
);
for (const outcome of probeOutcomes) {
if (outcome.probe.status === 'supported') supported += 1;
if (outcome.probe.status === 'unsupported') unsupported += 1;
if (outcome.probe.status === 'inconclusive') inconclusive += 1;
if (outcome.probe.status === 'skipped') skipped += 1;
if (outcome.touched) {
updatedRows += 1;
}
if (outcome.availabilityChanged) {
shouldRebuildRoutes = true;
}
if (outcome.failed) {
failed = true;
}
}
results.push({
accountId,
siteId: context.site.id,
status: failed ? 'failed' : 'success',
scanned: targets.length,
supported,
unsupported,
inconclusive,
skipped,
updatedRows,
message: failed
? 'model availability probe finished with partial failures'
: 'model availability probe finished',
});
} finally {
releaseProbeAccountLease(accountId);
}
}
let rebuiltRoutes = false;
if (input.rebuildRoutes !== false && shouldRebuildRoutes) {
await routeRefreshWorkflow.rebuildRoutesOnly();
rebuiltRoutes = true;
}
return summarizeProbeResults(results, rebuiltRoutes);
}
export function buildModelAvailabilityProbeTaskDedupeKey(accountId?: number | null): string {
const normalizedAccountId = Number.isFinite(accountId as number) && Number(accountId) > 0
? Math.trunc(Number(accountId))
: null;
return normalizedAccountId
? `model-availability-probe-${normalizedAccountId}`
: 'model-availability-probe-all';
}
export function queueModelAvailabilityProbeTask(input: {
accountId?: number;
title?: string;
}) {
const accountId = Number.isFinite(input.accountId as number) ? Math.trunc(input.accountId as number) : null;
const title = input.title || (accountId
? `探测模型可用性 #${accountId}`
: '探测模型可用性');
const dedupeKey = buildModelAvailabilityProbeTaskDedupeKey(accountId);
return startBackgroundTask(
{
type: 'model-probe',
title,
dedupeKey,
notifyOnFailure: true,
successMessage: (currentTask) => {
const summary = (currentTask.result as ModelAvailabilityProbeExecutionResult | undefined)?.summary;
if (!summary) return `${title}已完成`;
return `${title}完成:探测 ${summary.scanned},可用 ${summary.supported},不可用 ${summary.unsupported},不确定 ${summary.inconclusive}`;
},
failureMessage: (currentTask) => `${title}失败:${currentTask.error || 'unknown error'}`,
},
async () => executeModelAvailabilityProbe({
accountId: accountId ?? undefined,
rebuildRoutes: true,
}),
);
}
export function startModelAvailabilityProbeScheduler(intervalMs = config.modelAvailabilityProbeIntervalMs) {
stopModelAvailabilityProbeScheduler();
if (!config.modelAvailabilityProbeEnabled) {
return {
enabled: false,
intervalMs: 0,
};
}
const safeIntervalMs = Math.max(60_000, Math.trunc(intervalMs || 0));
probeSchedulerTimer = setInterval(() => {
void queueModelAvailabilityProbeTask({
title: '后台模型可用性探测',
});
}, safeIntervalMs);
probeSchedulerTimer.unref?.();
return {
enabled: true,
intervalMs: safeIntervalMs,
};
}
export function stopModelAvailabilityProbeScheduler() {
if (probeSchedulerTimer) {
clearInterval(probeSchedulerTimer);
probeSchedulerTimer = null;
}
}
export function __resetModelAvailabilityProbeExecutionStateForTests(): void {
probeAccountLeases.clear();
}
@@ -162,4 +162,71 @@ describe('proxyChannelCoordinator', () => {
second.lease.release();
}
});
it('exposes the set of currently active leased channels', async () => {
const lease = await proxyChannelCoordinator.acquireChannelLease({
channelId: 23,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
});
expect(lease.status).toBe('acquired');
if (lease.status !== 'acquired') return;
expect(proxyChannelCoordinator.getActiveChannelIds()).toEqual([23]);
lease.lease.release();
expect(proxyChannelCoordinator.getActiveChannelIds()).toEqual([]);
});
it('reports active and waiting load for a guarded session channel', async () => {
const first = await proxyChannelCoordinator.acquireChannelLease({
channelId: 31,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
});
expect(first.status).toBe('acquired');
if (first.status !== 'acquired') return;
const secondPromise = proxyChannelCoordinator.acquireChannelLease({
channelId: 31,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
});
await vi.advanceTimersByTimeAsync(0);
expect(proxyChannelCoordinator.getChannelLoadSnapshot({
channelId: 31,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toEqual({
channelId: 31,
sessionScoped: true,
concurrencyLimit: 1,
activeLeaseCount: 1,
waitingCount: 1,
loadRatio: 2,
saturated: true,
});
first.lease.release();
await vi.advanceTimersByTimeAsync(0);
const second = await secondPromise;
expect(second.status).toBe('acquired');
if (second.status === 'acquired') {
second.lease.release();
}
});
it('treats structured oauth providers as session-scoped in load snapshots', () => {
expect(proxyChannelCoordinator.getChannelLoadSnapshot({
channelId: 41,
accountExtraConfig: JSON.stringify({ credentialMode: 'session' }),
accountOauthProvider: 'codex',
})).toEqual({
channelId: 41,
sessionScoped: true,
concurrencyLimit: 1,
activeLeaseCount: 0,
waitingCount: 0,
loadRatio: 0,
saturated: false,
});
});
});
@@ -24,6 +24,16 @@ type ChannelRuntimeState = {
queue: ChannelWaiter[];
};
export type ProxyChannelLoadSnapshot = {
channelId: number;
sessionScoped: boolean;
concurrencyLimit: number;
activeLeaseCount: number;
waitingCount: number;
loadRatio: number;
saturated: boolean;
};
export type ProxyChannelLease = {
channelId: number;
isActive(): boolean;
@@ -104,9 +114,15 @@ function getOrCreateChannelRuntimeState(channelId: number): ChannelRuntimeState
return state;
}
function pruneCancelledWaiters(state: ChannelRuntimeState): void {
if (state.queue.length <= 0) return;
state.queue = state.queue.filter((waiter) => !waiter.cancelled);
}
function maybeDeleteChannelRuntimeState(channelId: number): void {
const state = channelRuntimeStates.get(channelId);
if (!state) return;
pruneCancelledWaiters(state);
if (state.activeLeaseIds.size <= 0 && state.queue.every((waiter) => waiter.cancelled)) {
channelRuntimeStates.delete(channelId);
}
@@ -177,6 +193,62 @@ class ProxyChannelCoordinator {
stickySessionBindings.delete(normalizedKey);
}
getActiveChannelIds(): number[] {
const ids: number[] = [];
for (const [channelId, state] of channelRuntimeStates.entries()) {
pruneCancelledWaiters(state);
if (state.activeLeaseIds.size > 0) {
ids.push(channelId);
}
}
return ids;
}
getChannelLoadSnapshot(input: {
channelId: number;
accountExtraConfig?: string | null;
accountOauthProvider?: string | null;
}): ProxyChannelLoadSnapshot {
const channelId = Math.trunc(input.channelId || 0);
const sessionScoped = isSessionScopedChannel({
extraConfig: input.accountExtraConfig,
oauthProvider: input.accountOauthProvider,
});
const concurrencyLimit = getChannelConcurrencyLimit({
extraConfig: input.accountExtraConfig,
oauthProvider: input.accountOauthProvider,
});
const state = channelId > 0 ? channelRuntimeStates.get(channelId) : null;
if (state) {
pruneCancelledWaiters(state);
}
const activeLeaseCount = state?.activeLeaseIds.size ?? 0;
const waitingCount = state?.queue.length ?? 0;
const denominator = concurrencyLimit > 0 ? concurrencyLimit : 1;
return {
channelId,
sessionScoped,
concurrencyLimit,
activeLeaseCount,
waitingCount,
loadRatio: (activeLeaseCount + waitingCount) / denominator,
saturated: concurrencyLimit > 0 && activeLeaseCount >= concurrencyLimit,
};
}
getChannelLoadSnapshots(input: Array<{
channelId: number;
accountExtraConfig?: string | null;
accountOauthProvider?: string | null;
}>): Map<number, ProxyChannelLoadSnapshot> {
const snapshots = new Map<number, ProxyChannelLoadSnapshot>();
for (const item of input) {
const snapshot = this.getChannelLoadSnapshot(item);
snapshots.set(snapshot.channelId, snapshot);
}
return snapshots;
}
async acquireChannelLease(input: {
channelId: number;
accountExtraConfig?: string | null;
@@ -202,6 +274,7 @@ class ProxyChannelCoordinator {
}
const state = getOrCreateChannelRuntimeState(channelId);
pruneCancelledWaiters(state);
if (state.activeLeaseIds.size < concurrencyLimit) {
return {
status: 'acquired',
@@ -226,6 +299,7 @@ class ProxyChannelCoordinator {
waiter.timer = setTimeout(() => {
waiter.cancelled = true;
waiter.timer = null;
pruneCancelledWaiters(state);
maybeDeleteChannelRuntimeState(channelId);
resolve({
status: 'timeout',
@@ -285,6 +359,7 @@ class ProxyChannelCoordinator {
private drainQueue(channelId: number): void {
const state = channelRuntimeStates.get(channelId);
if (!state) return;
pruneCancelledWaiters(state);
const concurrencyLimit = Math.max(0, Math.trunc(config.proxySessionChannelConcurrencyLimit || 0));
while (state.activeLeaseIds.size < concurrencyLimit && state.queue.length > 0) {
const waiter = state.queue.shift();
@@ -1,7 +1,7 @@
import { readRuntimeResponseText } from '../proxy-core/executors/types.js';
import type {
EndpointAttemptSuccessContext,
} from '../routes/proxy/endpointFlow.js';
} from '../proxy-core/orchestration/endpointFlow.js';
import {
finalizeProxyDebugTrace,
insertProxyDebugAttempt,
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { shouldRetryProxyRequest } from './proxyRetryPolicy.js';
import { shouldAbortSameSiteEndpointFallback, shouldRetryProxyRequest } from './proxyRetryPolicy.js';
describe('proxyRetryPolicy', () => {
it('retries on rate limit and server errors', () => {
@@ -52,4 +52,16 @@ describe('proxyRetryPolicy', () => {
shouldRetryProxyRequest(400, '{"error":{"message":"invalid timeout parameter"}}'),
).toBe(false);
});
it('aborts same-site endpoint fallback on rate-limit and quota responses', () => {
expect(
shouldAbortSameSiteEndpointFallback(429, '{"error":{"message":"rate limit exceeded"}}'),
).toBe(true);
expect(
shouldAbortSameSiteEndpointFallback(429, '{"error":{"message":"quota exceeded"}}'),
).toBe(true);
expect(
shouldAbortSameSiteEndpointFallback(429, '{"error":{"message":"too many requests"}}'),
).toBe(true);
});
});
+25
View File
@@ -9,6 +9,7 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
/model.*does\s+not\s+exist/i,
/no\s+such\s+model/i,
/unknown\s+model/i,
/unknown\s+provider\s+for\s+model/i,
/invalid\s+model/i,
/model[_\s-]?not[_\s-]?found/i,
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
@@ -54,6 +55,23 @@ const NON_RETRYABLE_REQUEST_PATTERNS: RegExp[] = [
/unsupported\s+media\s+type/i,
];
const SAME_SITE_ENDPOINT_ABORT_PATTERNS: RegExp[] = [
/\b429\b/i,
/too\s+many\s+requests/i,
/rate\s+limit/i,
/quota(?:\s+exceeded)?/i,
/bad\s+gateway/i,
/gateway\s+time-?out/i,
/service\s+unavailable/i,
/temporar(?:y|ily)\s+unavailable/i,
/cpu\s+overloaded/i,
/connection\s+reset/i,
/connection\s+refused/i,
/econnreset/i,
/econnrefused/i,
...RETRYABLE_TIMEOUT_PATTERNS,
];
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
const text = (rawMessage || '').trim();
if (!text) return false;
@@ -76,3 +94,10 @@ export function shouldRetryProxyRequest(status: number, upstreamErrorText?: stri
if (status === 400 || status === 404 || status === 422) return false;
return false;
}
export function shouldAbortSameSiteEndpointFallback(status: number, upstreamErrorText?: string | null): boolean {
if (status < 500 && status !== 408 && status !== 429) {
return false;
}
return matchesAnyPattern(SAME_SITE_ENDPOINT_ABORT_PATTERNS, upstreamErrorText);
}
+1
View File
@@ -0,0 +1 @@
export { dispatchRuntimeRequest } from '../routes/proxy/runtimeExecutor.js';
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const resolveUpstreamEndpointCandidatesMock = vi.fn();
const buildUpstreamEndpointRequestMock = vi.fn();
const dispatchRuntimeRequestMock = vi.fn();
const resolveChannelProxyUrlMock = vi.fn();
const withSiteRecordProxyRequestInitMock = vi.fn();
vi.mock('../routes/proxy/upstreamEndpoint.js', () => ({
resolveUpstreamEndpointCandidates: (...args: unknown[]) => resolveUpstreamEndpointCandidatesMock(...args),
buildUpstreamEndpointRequest: (...args: unknown[]) => buildUpstreamEndpointRequestMock(...args),
}));
vi.mock('../routes/proxy/runtimeExecutor.js', () => ({
dispatchRuntimeRequest: (...args: unknown[]) => dispatchRuntimeRequestMock(...args),
}));
vi.mock('./siteProxy.js', () => ({
resolveChannelProxyUrl: (...args: unknown[]) => resolveChannelProxyUrlMock(...args),
withSiteRecordProxyRequestInit: (...args: unknown[]) => withSiteRecordProxyRequestInitMock(...args),
}));
describe('probeRuntimeModel', () => {
const site = {
id: 1,
name: 'probe-site',
url: 'https://probe.example.com',
platform: 'new-api',
status: 'active',
} as any;
const account = {
id: 1,
siteId: 1,
username: 'probe-user',
accessToken: '',
apiToken: 'sk-probe',
status: 'active',
extraConfig: null,
} as any;
beforeEach(() => {
vi.resetModules();
resolveUpstreamEndpointCandidatesMock.mockReset();
buildUpstreamEndpointRequestMock.mockReset();
dispatchRuntimeRequestMock.mockReset();
resolveChannelProxyUrlMock.mockReset();
withSiteRecordProxyRequestInitMock.mockReset();
buildUpstreamEndpointRequestMock.mockReturnValue({
path: '/v1/chat/completions',
headers: { 'content-type': 'application/json' },
body: { model: 'gpt-5.4' },
runtime: {
executor: 'default',
modelName: 'gpt-5.4',
stream: false,
},
});
resolveChannelProxyUrlMock.mockReturnValue(null);
withSiteRecordProxyRequestInitMock.mockImplementation(async (_site: unknown, init: RequestInit) => init);
});
it('returns an inconclusive result instead of throwing when endpoint resolution fails', async () => {
resolveUpstreamEndpointCandidatesMock.mockRejectedValue(new Error('resolution failed'));
const { probeRuntimeModel } = await import('./runtimeModelProbe.js');
const result = await probeRuntimeModel({
site,
account,
modelName: 'gpt-5.4',
timeoutMs: 10,
});
expect(result.status).toBe('inconclusive');
expect(result.reason).toContain('resolution failed');
expect(result.latencyMs).not.toBeNull();
});
it('uses the remaining timeout budget for the runtime request phase', async () => {
resolveUpstreamEndpointCandidatesMock.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 15));
return ['chat'];
});
dispatchRuntimeRequestMock.mockImplementation(async (input: {
buildInit: (requestUrl: string, request: Record<string, unknown>) => Promise<RequestInit>;
request: Record<string, unknown>;
targetUrl?: string;
}) => {
const init = await input.buildInit(input.targetUrl || 'https://probe.example.com/v1/chat/completions', input.request);
const signal = init.signal as AbortSignal | undefined;
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 40);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason ?? new Error('aborted'));
}, { once: true });
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
});
const { probeRuntimeModel } = await import('./runtimeModelProbe.js');
const startedAt = Date.now();
const result = await probeRuntimeModel({
site,
account,
modelName: 'gpt-5.4',
timeoutMs: 30,
});
const elapsedMs = Date.now() - startedAt;
expect(result.status).toBe('inconclusive');
expect(result.latencyMs).not.toBeNull();
expect(elapsedMs).toBeLessThan(200);
});
});
+250
View File
@@ -0,0 +1,250 @@
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
import { buildOauthProviderHeaders } from './oauth/service.js';
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from './siteProxy.js';
import { dispatchRuntimeRequest } from './runtimeDispatch.js';
import {
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
type UpstreamEndpoint,
} from './upstreamEndpointRuntime.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../proxy-core/orchestration/endpointFlow.js';
import type { schema } from '../db/index.js';
export type RuntimeModelProbeStatus = 'supported' | 'unsupported' | 'inconclusive' | 'skipped';
export type RuntimeModelProbeResult = {
status: RuntimeModelProbeStatus;
latencyMs: number | null;
reason: string;
};
const NON_CONVERSATION_MODEL_PATTERNS = [
/(^|[-_/])embedding(s)?($|[-_/])/i,
/(^|[-_/])rerank($|[-_/])/i,
/(^|[-_/])moderation($|[-_/])/i,
/(^|[-_/])whisper($|[-_/])/i,
/(^|[-_/])tts($|[-_/])/i,
/(^|[-_/])transcribe|transcription/i,
/(^|[-_/])dall-e($|[-_/])/i,
/(^|[-_/])imagen($|[-_/])/i,
/(^|[-_/])veo($|[-_/])/i,
/(^|[-_/])cogvideo($|[-_/])/i,
];
const DEFINITE_UNSUPPORTED_PATTERNS = [
/no such model/i,
/unknown model/i,
/unsupported model/i,
/invalid model/i,
/model[^]{0,80}(does not exist|not found|not available|unavailable|unsupported|invalid|disabled)/i,
/(does not exist|not found|not available|unavailable|unsupported|invalid|disabled)[^]{0,40}model/i,
/模型[^]{0,40}(不存在|不可用|不支持|无效|禁用|未开通|未开放)/,
/(不存在|不可用|不支持|无效|禁用)[^]{0,20}模型/,
/model[^]{0,80}(access denied|permission|forbidden|not allowed)/i,
/模型[^]{0,40}(无权限|未授权|禁止访问)/,
];
function isLikelyConversationModel(modelName: string): boolean {
const normalized = String(modelName || '').trim();
if (!normalized) return false;
if (normalized.startsWith('__')) return false;
return !NON_CONVERSATION_MODEL_PATTERNS.some((pattern) => pattern.test(normalized));
}
function classifyUnsupportedFailure(status: number, rawErrorText: string): boolean {
if (![400, 403, 404, 422].includes(status)) return false;
const normalized = String(rawErrorText || '').trim();
if (!normalized) return false;
return DEFINITE_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(normalized));
}
function buildProbeBody(modelName: string): Record<string, unknown> {
return {
model: modelName,
messages: [
{
role: 'user',
content: 'Reply with OK.',
},
],
max_tokens: 8,
stream: false,
};
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function resolveRemainingTimeoutMs(deadlineAtMs: number, timeoutLabel: string): number {
const remainingMs = deadlineAtMs - Date.now();
if (remainingMs <= 0) {
throw new Error(timeoutLabel);
}
return remainingMs;
}
export async function probeRuntimeModel(input: {
site: typeof schema.sites.$inferSelect;
account: typeof schema.accounts.$inferSelect;
modelName: string;
timeoutMs: number;
tokenValue?: string | null;
}): Promise<RuntimeModelProbeResult> {
if (!isLikelyConversationModel(input.modelName)) {
return {
status: 'skipped',
latencyMs: null,
reason: 'skipped non-conversation model probe',
};
}
const oauth = getOauthInfoFromAccount(input.account);
const tokenValue = String(
input.tokenValue
|| (oauth ? input.account.accessToken : input.account.apiToken)
|| '',
).trim();
if (!tokenValue) {
return {
status: 'inconclusive',
latencyMs: null,
reason: 'missing credential for probe',
};
}
const startedAt = Date.now();
const deadlineAtMs = startedAt + Math.max(1, input.timeoutMs);
try {
const endpointCandidates = await withTimeout(
() => resolveUpstreamEndpointCandidates(
{
site: input.site,
account: input.account,
},
input.modelName,
'openai',
input.modelName,
),
resolveRemainingTimeoutMs(
deadlineAtMs,
`runtime model probe candidate resolution timeout (${Math.round(input.timeoutMs / 1000)}s)`,
),
`runtime model probe candidate resolution timeout (${Math.round(input.timeoutMs / 1000)}s)`,
);
if (endpointCandidates.length <= 0) {
return {
status: 'inconclusive',
latencyMs: Date.now() - startedAt,
reason: 'no compatible probe endpoint candidates',
};
}
const providerHeaders = buildOauthProviderHeaders({
account: input.account,
downstreamHeaders: {},
});
const openaiBody = buildProbeBody(input.modelName);
const channelProxyUrl = resolveChannelProxyUrl(input.site, input.account.extraConfig);
const abortController = new AbortController();
const remainingExecutionTimeoutMs = resolveRemainingTimeoutMs(
deadlineAtMs,
`runtime model probe timeout (${Math.round(input.timeoutMs / 1000)}s)`,
);
const abortTimer = setTimeout(() => {
abortController.abort(new Error(`runtime model probe timeout (${Math.round(input.timeoutMs / 1000)}s)`));
}, remainingExecutionTimeoutMs);
abortTimer.unref?.();
const buildRequest = (endpoint: UpstreamEndpoint): BuiltEndpointRequest => {
const request = buildUpstreamEndpointRequest({
endpoint,
modelName: input.modelName,
stream: false,
tokenValue,
oauthProvider: oauth?.provider,
oauthProjectId: oauth?.projectId,
sitePlatform: input.site.platform,
siteUrl: input.site.url,
openaiBody,
downstreamFormat: 'openai',
downstreamHeaders: {},
providerHeaders,
});
return {
endpoint,
path: request.path,
headers: request.headers,
body: request.body as Record<string, unknown>,
runtime: request.runtime,
};
};
const dispatchRequest = async (
request: BuiltEndpointRequest,
targetUrl: string,
) => (
dispatchRuntimeRequest({
siteUrl: input.site.url,
targetUrl,
request,
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(
input.site,
{
method: 'POST',
headers: requestForFetch.headers,
body: JSON.stringify(requestForFetch.body),
signal: abortController.signal,
},
channelProxyUrl,
),
})
);
let result: Awaited<ReturnType<typeof executeEndpointFlow>>;
try {
result = await executeEndpointFlow({
siteUrl: input.site.url,
proxyUrl: channelProxyUrl,
endpointCandidates,
buildRequest,
dispatchRequest,
});
} finally {
clearTimeout(abortTimer);
}
const latencyMs = Date.now() - startedAt;
if (result.ok) {
await result.upstream.text().catch(() => undefined);
return {
status: 'supported',
latencyMs,
reason: 'probe succeeded',
};
}
const rawErrorText = String(result.rawErrText || result.errText || '').trim();
return {
status: classifyUnsupportedFailure(result.status || 0, rawErrorText) ? 'unsupported' : 'inconclusive',
latencyMs,
reason: rawErrorText || `probe failed with status ${result.status || 0}`,
};
} catch (error) {
return {
status: 'inconclusive',
latencyMs: Date.now() - startedAt,
reason: error instanceof Error ? error.message : 'probe failed',
};
}
}
+85 -1
View File
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
@@ -615,6 +615,90 @@ describe('TokenRouter runtime cache', () => {
expect(await router.selectChannel('gpt-4o-mini')).toBeNull();
});
it('clears short-window cooldown on sibling channels after a successful recovery probe', async () => {
const site = await db.insert(schema.sites).values({
name: 'shared-credential-recovery-site',
url: 'https://shared-credential-recovery.example.com',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'shared-credential-recovery-user',
accessToken: 'shared-credential-recovery-access-token',
status: 'active',
oauthProvider: 'codex',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
},
}),
}).returning().get();
const primaryRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
routingStrategy: 'weighted',
enabled: true,
}).returning().get();
const siblingRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
routingStrategy: 'weighted',
enabled: true,
}).returning().get();
const primaryChannel = await db.insert(schema.routeChannels).values({
routeId: primaryRoute.id,
accountId: account.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siblingChannel = await db.insert(schema.routeChannels).values({
routeId: siblingRoute.id,
accountId: account.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(primaryChannel.id, {
status: 429,
errorText: JSON.stringify({
error: {
type: 'usage_limit_reached',
message: 'The usage limit has been reached',
},
}),
modelName: 'gpt-5.4',
});
await router.recordProbeSuccess(primaryChannel.id, 180, 'gpt-5.4');
const refreshedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, [primaryChannel.id, siblingChannel.id]))
.all();
expect(refreshedChannels).toEqual(expect.arrayContaining([
expect.objectContaining({
id: primaryChannel.id,
cooldownUntil: null,
consecutiveFailCount: 0,
cooldownLevel: 0,
}),
expect.objectContaining({
id: siblingChannel.id,
cooldownUntil: null,
consecutiveFailCount: 0,
cooldownLevel: 0,
}),
]));
});
it('round robins across all available channels regardless of priority', async () => {
const site = await db.insert(schema.sites).values({
name: 'round-robin-site',
@@ -7,6 +7,7 @@ import { eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
type ConfigModule = typeof import('../config.js');
type ProxyChannelCoordinatorModule = typeof import('./proxyChannelCoordinator.js');
const mockedCatalogRoutingCost = vi.fn<(
input: { siteId: number; accountId: number; modelName: string }
@@ -24,14 +25,20 @@ describe('TokenRouter selection scoring', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let tokenRouterTestUtils: TokenRouterModule['__tokenRouterTestUtils'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence'];
let filterRecentlyFailedCandidates: TokenRouterModule['filterRecentlyFailedCandidates'];
let config: ConfigModule['config'];
let proxyChannelCoordinator: ProxyChannelCoordinatorModule['proxyChannelCoordinator'];
let resetProxyChannelCoordinatorState: ProxyChannelCoordinatorModule['resetProxyChannelCoordinatorState'];
let dataDir = '';
let idSeed = 0;
let originalRoutingWeights: typeof config.routingWeights;
let originalRoutingFallbackUnitCost: number;
let originalProxySessionChannelConcurrencyLimit: number;
let originalProxySessionChannelQueueWaitMs: number;
const nextId = () => {
idSeed += 1;
@@ -46,21 +53,30 @@ describe('TokenRouter selection scoring', () => {
const dbModule = await import('../db/index.js');
const tokenRouterModule = await import('./tokenRouter.js');
const configModule = await import('../config.js');
const coordinatorModule = await import('./proxyChannelCoordinator.js');
db = dbModule.db;
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
tokenRouterTestUtils = tokenRouterModule.__tokenRouterTestUtils;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence;
filterRecentlyFailedCandidates = tokenRouterModule.filterRecentlyFailedCandidates;
config = configModule.config;
proxyChannelCoordinator = coordinatorModule.proxyChannelCoordinator;
resetProxyChannelCoordinatorState = coordinatorModule.resetProxyChannelCoordinatorState;
originalRoutingWeights = { ...config.routingWeights };
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
originalProxySessionChannelConcurrencyLimit = config.proxySessionChannelConcurrencyLimit;
originalProxySessionChannelQueueWaitMs = config.proxySessionChannelQueueWaitMs;
});
beforeEach(async () => {
idSeed = 0;
mockedCatalogRoutingCost.mockReset();
mockedCatalogRoutingCost.mockReturnValue(null);
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
@@ -69,13 +85,17 @@ describe('TokenRouter selection scoring', () => {
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
resetProxyChannelCoordinatorState();
});
afterAll(() => {
config.routingWeights = { ...originalRoutingWeights };
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
config.proxySessionChannelConcurrencyLimit = originalProxySessionChannelConcurrencyLimit;
config.proxySessionChannelQueueWaitMs = originalProxySessionChannelQueueWaitMs;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
resetProxyChannelCoordinatorState();
delete process.env.DATA_DIR;
});
@@ -96,7 +116,7 @@ describe('TokenRouter selection scoring', () => {
}).returning().get();
}
async function createAccount(siteId: number, usernamePrefix: string) {
async function createAccount(siteId: number, usernamePrefix: string, options: { extraConfig?: string | null } = {}) {
const id = nextId();
return await db.insert(schema.accounts).values({
siteId,
@@ -104,6 +124,7 @@ describe('TokenRouter selection scoring', () => {
accessToken: `access-${id}`,
apiToken: `sk-${id}`,
status: 'active',
extraConfig: options.extraConfig ?? null,
}).returning().get();
}
@@ -126,7 +147,11 @@ describe('TokenRouter selection scoring', () => {
usageWeight: 0,
};
const route = await createRoute('gpt-5.2');
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const site = await createSite('sticky-site');
const account = await createAccount(site.id, 'sticky-user');
const tokenA = await createToken(account.id, 'sticky-a');
@@ -165,6 +190,27 @@ describe('TokenRouter selection scoring', () => {
await expect(router.selectPreferredChannel('gpt-5.2', preferredChannel.id)).resolves.toBeNull();
});
it('avoids recently failed candidates by default when healthy alternatives exist', () => {
const nowMs = Date.now();
const filtered = filterRecentlyFailedCandidates([
{
channel: {
failCount: 3,
lastFailAt: new Date(nowMs).toISOString(),
},
},
{
channel: {
failCount: 0,
lastFailAt: null,
},
},
], nowMs);
expect(filtered).toHaveLength(1);
expect(filtered[0]?.channel.failCount).toBe(0);
});
it('normalizes probability across channels on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
@@ -790,6 +836,66 @@ describe('TokenRouter selection scoring', () => {
expect(weakCandidate?.reason || '').toContain('成功率=40.0%');
});
it('stable_first ranks recent and fallback success rate ahead of balance-heavy weak sites', async () => {
config.routingWeights = {
baseWeightFactor: 0,
valueScoreFactor: 1,
costWeight: 0.1,
balanceWeight: 10,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteStable = await createSite('stable-rate-front');
const accountStable = await createAccount(siteStable.id, 'stable-rate-user-front');
const tokenStable = await createToken(accountStable.id, 'stable-rate-token-front');
const stableChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountStable.id,
tokenId: tokenStable.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 80,
failCount: 4,
totalLatencyMs: 80 * 380,
}).returning().get();
const siteWeak = await createSite('stable-rate-back');
const accountWeak = await createAccount(siteWeak.id, 'stable-rate-user-back');
await db.update(schema.accounts).set({
balance: 999999,
}).where(eq(schema.accounts.id, accountWeak.id)).run();
const tokenWeak = await createToken(accountWeak.id, 'stable-rate-token-back');
const weakChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountWeak.id,
tokenId: tokenWeak.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 5,
failCount: 21,
totalLatencyMs: 5 * 1600,
}).returning().get();
const decision = await new TokenRouter().explainSelection('gpt-5.4');
const stableCandidate = decision.candidates.find((candidate) => candidate.channelId === stableChannel.id);
const weakCandidate = decision.candidates.find((candidate) => candidate.channelId === weakChannel.id);
expect(stableCandidate).toBeTruthy();
expect(weakCandidate).toBeTruthy();
expect(decision.selectedChannelId).toBe(stableChannel.id);
expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0);
expect(stableCandidate?.reason || '').toContain('近期成功率=');
expect(weakCandidate?.reason || '').toContain('综合近期成功率=');
});
it('reloads persisted runtime health after in-memory reset', async () => {
config.routingWeights = {
baseWeightFactor: 1,
@@ -856,6 +962,79 @@ describe('TokenRouter selection scoring', () => {
expect(candidateA?.reason || '').toContain('运行时健康=');
});
it('keeps a recovered stable_first site behind healthier peers until recent success rebuilds', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.3',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteRecovered = await createSite('stable-recovery-a');
const accountRecovered = await createAccount(siteRecovered.id, 'stable-recovery-user-a');
const tokenRecovered = await createToken(accountRecovered.id, 'stable-recovery-token-a');
const recoveredChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountRecovered.id,
tokenId: tokenRecovered.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteHealthy = await createSite('stable-recovery-b');
const accountHealthy = await createAccount(siteHealthy.id, 'stable-recovery-user-b');
const tokenHealthy = await createToken(accountHealthy.id, 'stable-recovery-token-b');
const healthyChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountHealthy.id,
tokenId: tokenHealthy.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(recoveredChannel.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-5.3',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
consecutiveFailCount: 0,
cooldownLevel: 0,
}).where(eq(schema.routeChannels.id, recoveredChannel.id)).run();
await router.recordSuccess(recoveredChannel.id, 900, 0, 'gpt-5.3');
for (let index = 0; index < 4; index += 1) {
await router.recordSuccess(healthyChannel.id, 320, 0, 'gpt-5.3');
}
invalidateTokenRouterCache();
const preview = await router.previewSelectedChannel('gpt-5.3');
const decision = await router.explainSelection('gpt-5.3');
const recoveredCandidate = decision.candidates.find((candidate) => candidate.channelId === recoveredChannel.id);
const healthyCandidate = decision.candidates.find((candidate) => candidate.channelId === healthyChannel.id);
expect(preview?.channel.id).toBe(healthyChannel.id);
expect(decision.selectedChannelId).toBe(healthyChannel.id);
expect((recoveredCandidate?.probability || 0)).toBeLessThan(healthyCandidate?.probability || 0);
expect(recoveredCandidate?.reason || '').toContain('近期成功率=');
expect(healthyCandidate?.reason || '').toContain('近期成功率=');
});
it('penalizes the failed model more than unrelated models on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
@@ -934,6 +1113,90 @@ describe('TokenRouter selection scoring', () => {
expect(gptCandidateA?.reason || '').toContain('模型=');
});
it('treats unknown provider for model as model-scoped degradation instead of opening a site breaker', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const gptRoute = await createRoute('gpt-5.4');
const claudeRoute = await createRoute('claude-sonnet-4-6');
const siteA = await createSite('unknown-provider-a');
const accountA = await createAccount(siteA.id, 'unknown-provider-user-a');
const tokenA = await createToken(accountA.id, 'unknown-provider-token-a');
const gptChannelA = await db.insert(schema.routeChannels).values({
routeId: gptRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: claudeRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).run();
const siteB = await createSite('unknown-provider-b');
const accountB = await createAccount(siteB.id, 'unknown-provider-user-b');
const tokenB = await createToken(accountB.id, 'unknown-provider-token-b');
await db.insert(schema.routeChannels).values([
{
routeId: gptRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
{
routeId: claudeRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
]).run();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(gptChannelA.id, {
status: 502,
errorText: 'unknown provider for model gpt-5.4',
modelName: 'gpt-5.4',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, gptChannelA.id)).run();
invalidateTokenRouterCache();
const gptDecision = await router.explainSelection('gpt-5.4');
const claudeDecision = await router.explainSelection('claude-sonnet-4-6');
const gptCandidateA = gptDecision.candidates.find((candidate) => candidate.siteName.startsWith('unknown-provider-a'));
const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('unknown-provider-a'));
expect(gptCandidateA).toBeTruthy();
expect(claudeCandidateA).toBeTruthy();
expect(gptDecision.summary.join(' ')).not.toContain('站点熔断避让');
expect(claudeDecision.summary.join(' ')).not.toContain('站点熔断避让');
expect(gptCandidateA?.reason || '').not.toContain('站点熔断');
expect(claudeCandidateA?.reason || '').not.toContain('站点熔断');
expect((gptCandidateA?.probability || 0)).toBeLessThan((claudeCandidateA?.probability || 0));
expect(gptCandidateA?.reason || '').toContain('模型=');
});
it('stable_first deterministically chooses the healthiest candidate', async () => {
config.routingWeights = {
baseWeightFactor: 1,
@@ -993,4 +1256,282 @@ describe('TokenRouter selection scoring', () => {
expect(decision.summary.join(' ')).toContain('稳定优先');
expect(decision.selectedChannelId).toBe(channelB.id);
});
it('stable_first rotates across sites that remain inside the stable pool', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteA = await createSite('stable-pool-a');
const accountA = await createAccount(siteA.id, 'stable-pool-user-a');
const tokenA = await createToken(accountA.id, 'stable-pool-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('stable-pool-b');
const accountB = await createAccount(siteB.id, 'stable-pool-user-b');
const tokenB = await createToken(accountB.id, 'stable-pool-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 9,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const first = await router.selectChannel('gpt-5.4');
const second = await router.selectChannel('gpt-5.4');
const third = await router.selectChannel('gpt-5.4');
const decision = await router.explainSelection('gpt-5.4');
expect(first?.channel.id).toBe(channelA.id);
expect(second?.channel.id).toBe(channelB.id);
expect(third?.channel.id).toBe(channelA.id);
expect(decision.summary.join(' ')).toContain('主池站点 2');
});
it('stable_first rotates across healthy sites in configured priority order instead of stopping at the first layer', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4.1',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteA = await createSite('ordered-stable-a');
const accountA = await createAccount(siteA.id, 'ordered-stable-user-a');
const tokenA = await createToken(accountA.id, 'ordered-stable-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('ordered-stable-b');
const accountB = await createAccount(siteB.id, 'ordered-stable-user-b');
const tokenB = await createToken(accountB.id, 'ordered-stable-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 4,
weight: 10,
enabled: true,
}).returning().get();
const siteC = await createSite('ordered-stable-c');
const accountC = await createAccount(siteC.id, 'ordered-stable-user-c');
const tokenC = await createToken(accountC.id, 'ordered-stable-token-c');
const channelC = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountC.id,
tokenId: tokenC.id,
priority: 8,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const first = await router.selectChannel('gpt-4.1');
const second = await router.selectChannel('gpt-4.1');
const third = await router.selectChannel('gpt-4.1');
const fourth = await router.selectChannel('gpt-4.1');
const decision = await router.explainSelection('gpt-4.1');
expect(first?.channel.id).toBe(channelA.id);
expect(second?.channel.id).toBe(channelB.id);
expect(third?.channel.id).toBe(channelC.id);
expect(fourth?.channel.id).toBe(channelA.id);
expect(decision.summary.join(' ')).toContain('按配置顺序轮询站点');
});
it('stable_first gives observation-pool sites occasional real traffic without background probing', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.4-probe-free',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const sitePrimary = await createSite('observation-primary');
const accountPrimary = await createAccount(sitePrimary.id, 'observation-user-primary');
const tokenPrimary = await createToken(accountPrimary.id, 'observation-token-primary');
const primaryChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountPrimary.id,
tokenId: tokenPrimary.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 48,
failCount: 0,
totalLatencyMs: 48 * 320,
}).returning().get();
const siteObservation = await createSite('observation-candidate');
const accountObservation = await createAccount(siteObservation.id, 'observation-user-candidate');
const tokenObservation = await createToken(accountObservation.id, 'observation-token-candidate');
const observationChannel = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountObservation.id,
tokenId: tokenObservation.id,
priority: 1,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
const selectedChannelIds: number[] = [];
for (let index = 0; index < 23; index += 1) {
const selected = await router.selectChannel('gpt-5.4-probe-free');
selectedChannelIds.push(selected?.channel.id ?? 0);
}
let decision = await router.explainSelection('gpt-5.4-probe-free');
let observationCandidate = decision.candidates.find((candidate) => candidate.channelId === observationChannel.id);
let primaryCandidate = decision.candidates.find((candidate) => candidate.channelId === primaryChannel.id);
expect(observationCandidate?.probability).toBe(100);
expect(primaryCandidate?.probability).toBe(0);
expect(decision.summary.join(' ')).toContain('本次命中观察池灰度流量');
const observationSelected = await router.selectChannel('gpt-5.4-probe-free');
selectedChannelIds.push(observationSelected?.channel.id ?? 0);
decision = await router.explainSelection('gpt-5.4-probe-free');
observationCandidate = decision.candidates.find((candidate) => candidate.channelId === observationChannel.id);
primaryCandidate = decision.candidates.find((candidate) => candidate.channelId === primaryChannel.id);
expect(selectedChannelIds.filter((channelId) => channelId === observationChannel.id)).toHaveLength(1);
expect(selectedChannelIds.filter((channelId) => channelId === primaryChannel.id).length).toBeGreaterThan(20);
expect(decision.summary.join(' ')).toContain('观察池站点 1');
expect(decision.summary.join(' ')).toContain('还需 23 次主池请求');
expect(observationCandidate?.reason || '').toContain('观察池');
expect(observationCandidate?.probability).toBe(0);
expect(primaryCandidate?.reason || '').toContain('主池');
expect((primaryCandidate?.probability || 0)).toBeGreaterThan(0);
});
it('caps the stable_first rotation cache size', () => {
invalidateTokenRouterCache();
for (let index = 0; index < 1200; index += 1) {
tokenRouterTestUtils.rememberStableFirstSiteSelectionForKey(`route:${index}`, (index % 7) + 1);
}
expect(tokenRouterTestUtils.getStableFirstRotationCacheSize()).toBeLessThanOrEqual(1024);
});
it('penalizes saturated session-scoped channels using runtime load snapshots', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
config.proxySessionChannelConcurrencyLimit = 1;
config.proxySessionChannelQueueWaitMs = 5_000;
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const sessionExtraConfig = JSON.stringify({ credentialMode: 'session' });
const siteBusy = await createSite('runtime-load-busy');
const accountBusy = await createAccount(siteBusy.id, 'runtime-load-user-busy', {
extraConfig: sessionExtraConfig,
});
const tokenBusy = await createToken(accountBusy.id, 'runtime-load-token-busy');
const channelBusy = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountBusy.id,
tokenId: tokenBusy.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteFree = await createSite('runtime-load-free');
const accountFree = await createAccount(siteFree.id, 'runtime-load-user-free', {
extraConfig: sessionExtraConfig,
});
const tokenFree = await createToken(accountFree.id, 'runtime-load-token-free');
const channelFree = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountFree.id,
tokenId: tokenFree.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const activeLease = await proxyChannelCoordinator.acquireChannelLease({
channelId: channelBusy.id,
accountExtraConfig: accountBusy.extraConfig,
});
expect(activeLease.status).toBe('acquired');
if (activeLease.status !== 'acquired') return;
const queuedLeasePromise = proxyChannelCoordinator.acquireChannelLease({
channelId: channelBusy.id,
accountExtraConfig: accountBusy.extraConfig,
});
await Promise.resolve();
const router = new TokenRouter();
const preview = await router.previewSelectedChannel('gpt-5.2');
const decision = await router.explainSelection('gpt-5.2');
const busyCandidate = decision.candidates.find((candidate) => candidate.channelId === channelBusy.id);
const freeCandidate = decision.candidates.find((candidate) => candidate.channelId === channelFree.id);
expect(preview?.channel.id).toBe(channelFree.id);
expect(busyCandidate?.reason || '').toContain('会话负载=');
expect(busyCandidate?.reason || '').toContain('活跃=1/1');
expect(busyCandidate?.reason || '').toContain('等待=1');
expect((busyCandidate?.probability || 0)).toBeLessThan((freeCandidate?.probability || 0));
activeLease.lease.release();
const queuedLease = await queuedLeasePromise;
expect(queuedLease.status).toBe('acquired');
if (queuedLease.status === 'acquired') {
queuedLease.lease.release();
}
});
});
+2 -2
View File
@@ -81,7 +81,7 @@ describe('filterRecentlyFailedCandidates', () => {
expect(result.map((c) => c.id).sort()).toEqual(['a', 'b']);
});
it('keeps candidates unchanged when avoidSec is omitted', () => {
it('uses the default recent-failure backoff when avoidSec is omitted', () => {
const nowMs = Date.now();
const candidates: Candidate[] = [
{
@@ -101,7 +101,7 @@ describe('filterRecentlyFailedCandidates', () => {
];
const result = filterRecentlyFailedCandidates(candidates, nowMs);
expect(result).toEqual(candidates);
expect(result.map((candidate) => candidate.id)).toEqual(['healthy']);
});
it('does not penalize stale failures outside the avoidance window', () => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
export {
buildClaudeCountTokensUpstreamRequest,
buildUpstreamEndpointRequest,
resolveUpstreamEndpointCandidates,
type UpstreamEndpoint,
} from '../routes/proxy/upstreamEndpoint.js';
@@ -33,6 +33,7 @@ export type EndpointCapabilityProfile = {
hasNonImageFileInput: boolean;
hasRemoteDocumentUrl: boolean;
wantsNativeResponsesReasoning: boolean;
wantsContinuationAwareResponses: boolean;
};
type EndpointRuntimeState = {
@@ -88,6 +89,7 @@ export function buildEndpointCapabilityProfile(input?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
};
}): EndpointCapabilityProfile {
const conversationFileSummary = input?.requestCapabilities?.conversationFileSummary;
@@ -107,6 +109,7 @@ export function buildEndpointCapabilityProfile(input?: {
conversationFileSummary?.hasRemoteDocumentUrl === true
),
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
wantsContinuationAwareResponses: input?.requestCapabilities?.wantsContinuationAwareResponses === true,
};
}
@@ -131,6 +134,7 @@ function buildEndpointRuntimeStateKey(input: {
capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles',
capabilityProfile.hasRemoteDocumentUrl ? 'remoteurl' : 'noremoteurl',
capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning',
capabilityProfile.wantsContinuationAwareResponses ? 'continuation' : 'nocontinuation',
].join(':');
}
@@ -247,6 +251,7 @@ export function getUpstreamEndpointRuntimeStateSnapshot(input: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
};
}) {
const capabilityProfile = buildEndpointCapabilityProfile({
@@ -349,6 +354,7 @@ export function recordUpstreamEndpointSuccess(input: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
};
}): UpstreamEndpointRuntimeMemoryWrite | null {
const capabilityProfile = buildEndpointCapabilityProfile({
@@ -390,6 +396,7 @@ export function recordUpstreamEndpointFailure(input: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
wantsContinuationAwareResponses?: boolean;
};
}): UpstreamEndpointRuntimeMemoryWrite | null {
const capabilityProfile = buildEndpointCapabilityProfile({
@@ -1,5 +1,57 @@
import { type DownstreamFormat } from '../../shared/normalized.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function hasAnthropicContinuationHint(body: Record<string, unknown>): boolean {
if (asTrimmedString(body.previous_response_id)) return true;
if (asTrimmedString(body.prompt_cache_key)) return true;
if (isRecord(body.metadata) && asTrimmedString(body.metadata.user_id)) return true;
return false;
}
function hasOrphanAnthropicToolResult(body: Record<string, unknown>): boolean {
const messages = Array.isArray(body.messages) ? body.messages : [];
const seenToolUseIds = new Set<string>();
for (const message of messages) {
if (!isRecord(message) || !Array.isArray(message.content)) continue;
const role = asTrimmedString(message.role).toLowerCase();
for (const block of message.content) {
if (!isRecord(block)) continue;
const type = asTrimmedString(block.type).toLowerCase();
if (!type) continue;
if (role === 'assistant' && type === 'tool_use') {
const toolUseId = asTrimmedString(block.id);
if (toolUseId) seenToolUseIds.add(toolUseId);
continue;
}
if (type !== 'tool_result') continue;
const toolUseId = asTrimmedString(block.tool_use_id ?? block.toolUseId);
if (toolUseId && !seenToolUseIds.has(toolUseId)) {
return true;
}
}
}
return false;
}
export function shouldPreferResponsesForAnthropicContinuation(
body: Record<string, unknown> | null | undefined,
): boolean {
if (!body) return false;
return hasAnthropicContinuationHint(body) && hasOrphanAnthropicToolResult(body);
}
export function shouldRetryNormalizedMessagesBody(input: {
downstreamFormat: DownstreamFormat;
endpointPath: string;
@@ -62,6 +62,30 @@ describe('sanitizeAnthropicMessagesBody', () => {
]);
});
it('normalizes primitive content arrays into text blocks and drops unsupported entries', () => {
const result = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
messages: [
{
role: 'user',
content: ['hello', true, 42, null, { type: 'text', text: 'done' }],
},
],
});
expect(result.messages).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'hello' },
{ type: 'text', text: 'true' },
{ type: 'text', text: '42' },
{ type: 'text', text: 'done', cache_control: { type: 'ephemeral' } },
],
},
]);
});
it('drops output_config.effort unless thinking stays adaptive', () => {
const enabled = sanitizeAnthropicMessagesBody({
model: 'claude-opus-4-6',
@@ -425,6 +425,43 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
return { ...item };
}
function sanitizeAnthropicMessage(message: Record<string, unknown>): Record<string, unknown> | null {
const next: Record<string, unknown> = { ...message };
const rawContent = next.content;
if (Array.isArray(rawContent)) {
const content = rawContent
.map((item) => {
if (isRecord(item)) return sanitizeAnthropicContentBlock(item);
if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
return sanitizeAnthropicContentBlock(toAnthropicTextBlock(String(item)) ?? {});
}
return null;
})
.filter((item): item is Record<string, unknown> => item !== null);
if (content.length <= 0) return null;
next.content = content;
return next;
}
if (rawContent === null || rawContent === undefined) {
return null;
}
if (isRecord(rawContent)) {
const block = sanitizeAnthropicContentBlock(rawContent);
if (!block) return null;
next.content = [block];
return next;
}
if (typeof rawContent === 'number' || typeof rawContent === 'boolean') {
next.content = String(rawContent);
}
return typeof next.content === 'string' ? next : null;
}
function isCacheableAnthropicMessageBlock(item: Record<string, unknown>): boolean {
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'thinking' || type === 'redacted_thinking') return false;
@@ -710,15 +747,9 @@ export function sanitizeAnthropicMessagesBody(
const rawMessages = Array.isArray(sanitized.messages) ? sanitized.messages : [];
if (rawMessages.length > 0) {
sanitized.messages = rawMessages.map((message) => {
if (!isRecord(message)) return message;
const next = { ...message };
if (Array.isArray(next.content)) {
next.content = next.content
.map((item) => (isRecord(item) ? sanitizeAnthropicContentBlock(item) : item))
.filter((item) => item !== null);
}
return next;
});
if (!isRecord(message)) return null;
return sanitizeAnthropicMessage(message);
}).filter((message): message is Record<string, unknown> => message !== null);
}
if (autoOptimizeCacheControls) {
@@ -43,6 +43,66 @@ describe('anthropicMessagesInbound', () => {
});
});
it('drops null-content turns instead of rejecting the whole Claude request', () => {
const result = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [
{ role: 'user', content: 'hello' },
{ role: 'assistant', content: null },
],
});
expect(result.error).toBeUndefined();
expect(result.value?.parsed.upstreamBody.messages).toEqual([
{ role: 'user', content: 'hello' },
]);
expect(result.value?.parsed.claudeOriginalBody).toMatchObject({
messages: [
{ role: 'user', content: 'hello' },
],
});
});
it('preserves continuation hints for tool_result-only follow-up turns', () => {
const result = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
max_tokens: 256,
previous_response_id: 'resp_prev_1',
prompt_cache_key: 'cache-key-1',
messages: [
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_missing',
content: [{ type: 'text', text: '{"matches":1}' }],
},
{ type: 'text', text: 'continue' },
],
},
],
});
expect(result.error).toBeUndefined();
expect(result.value?.parsed.upstreamBody).toMatchObject({
previous_response_id: 'resp_prev_1',
prompt_cache_key: 'cache-key-1',
messages: [
{
role: 'tool',
tool_call_id: 'toolu_missing',
content: '{"matches":1}',
},
{
role: 'user',
content: 'continue',
},
],
});
});
it('normalizes adaptive effort and tool choice at the inbound boundary', () => {
const result = anthropicMessagesInbound.parse({
model: 'claude-opus-4-6',
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
extractResponsesTerminalResponseId,
isResponsesPreviousResponseNotFoundError,
shouldInferResponsesPreviousResponseId,
stripResponsesPreviousResponseId,
withResponsesPreviousResponseId,
} from './continuation.js';
describe('responses continuation helpers', () => {
it('infers previous_response_id only for tool-output follow-up turns that omit it', () => {
expect(shouldInferResponsesPreviousResponseId({
input: [{
type: 'function_call_output',
call_id: 'call_1',
output: '{"ok":true}',
}],
}, 'resp_prev_1')).toBe(true);
expect(shouldInferResponsesPreviousResponseId({
previous_response_id: 'resp_existing',
input: [{
type: 'function_call_output',
call_id: 'call_1',
output: '{"ok":true}',
}],
}, 'resp_prev_1')).toBe(false);
expect(shouldInferResponsesPreviousResponseId({
input: [{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'hello' }],
}],
}, 'resp_prev_1')).toBe(false);
});
it('adds and removes previous_response_id without mutating other fields', () => {
const body = {
model: 'gpt-5.4',
input: [{
type: 'function_call_output',
call_id: 'call_1',
output: '{"ok":true}',
}],
};
const withPrevious = withResponsesPreviousResponseId(body, 'resp_prev_1');
expect(withPrevious).toMatchObject({
model: 'gpt-5.4',
previous_response_id: 'resp_prev_1',
});
const stripped = stripResponsesPreviousResponseId(withPrevious);
expect(stripped.removed).toBe(true);
expect(stripped.body.previous_response_id).toBeUndefined();
expect(stripped.body.input).toEqual(body.input);
});
it('detects previous_response_not_found from either raw text or structured payloads', () => {
expect(isResponsesPreviousResponseNotFoundError({
rawErrText: JSON.stringify({
error: {
code: 'previous_response_not_found',
message: 'previous_response_not_found',
},
}),
})).toBe(true);
expect(isResponsesPreviousResponseNotFoundError({
payload: {
type: 'response.failed',
response: {
error: {
message: 'Previous response id not found',
type: 'invalid_request_error',
},
},
},
})).toBe(true);
expect(isResponsesPreviousResponseNotFoundError({
rawErrText: JSON.stringify({
error: {
code: 'rate_limit_exceeded',
message: 'too many requests',
},
}),
})).toBe(false);
});
it('extracts terminal response ids from responses payloads and websocket terminal events', () => {
expect(extractResponsesTerminalResponseId({
id: 'resp_1',
object: 'response',
status: 'completed',
})).toBe('resp_1');
expect(extractResponsesTerminalResponseId({
type: 'response.failed',
response: {
id: 'resp_2',
status: 'failed',
error: {
message: 'tool failed',
},
},
})).toBe('resp_2');
expect(extractResponsesTerminalResponseId({
type: 'response.created',
response: {
id: 'resp_3',
status: 'in_progress',
},
})).toBeNull();
expect(extractResponsesTerminalResponseId({
id: 'resp_in_progress',
object: 'response',
status: 'in_progress',
})).toBeNull();
});
});
@@ -0,0 +1,143 @@
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
const RESPONSES_TOOL_OUTPUT_TYPES = new Set([
'function_call_output',
'custom_tool_call_output',
]);
const RESPONSES_TERMINAL_STATUSES = new Set([
'completed',
'failed',
'incomplete',
]);
function collectResponsesErrorFragments(value: unknown): string[] {
if (!value) return [];
if (typeof value === 'string') return [value];
if (!isRecord(value)) return [];
const fragments = [
value.type,
value.code,
value.message,
value.reason,
]
.map((entry) => asTrimmedString(entry))
.filter(Boolean);
if (isRecord(value.error)) {
fragments.push(...collectResponsesErrorFragments(value.error));
}
if (isRecord(value.response)) {
fragments.push(...collectResponsesErrorFragments(value.response));
}
if (isRecord(value.incomplete_details)) {
fragments.push(...collectResponsesErrorFragments(value.incomplete_details));
}
return fragments;
}
function hasResponsesToolOutput(input: unknown): boolean {
if (!Array.isArray(input)) return false;
return input.some((item) => {
if (!isRecord(item)) return false;
const type = asTrimmedString(item.type).toLowerCase();
if (!RESPONSES_TOOL_OUTPUT_TYPES.has(type)) return false;
return asTrimmedString(item.call_id ?? item.id).length > 0;
});
}
export function shouldInferResponsesPreviousResponseId(
body: Record<string, unknown> | null | undefined,
candidatePreviousResponseId: unknown,
): candidatePreviousResponseId is string {
if (!body) return false;
if (asTrimmedString(body.previous_response_id)) return false;
const candidate = asTrimmedString(candidatePreviousResponseId);
if (!candidate) return false;
return hasResponsesToolOutput(body.input);
}
export function withResponsesPreviousResponseId(
body: Record<string, unknown>,
previousResponseId: string,
): Record<string, unknown> {
return {
...body,
previous_response_id: previousResponseId.trim(),
};
}
export function stripResponsesPreviousResponseId(
body: Record<string, unknown>,
): { body: Record<string, unknown>; removed: boolean } {
if (!Object.prototype.hasOwnProperty.call(body, 'previous_response_id')) {
return { body, removed: false };
}
const next = { ...body };
delete next.previous_response_id;
return { body: next, removed: true };
}
export function isResponsesPreviousResponseNotFoundError(input: {
rawErrText?: string | null;
payload?: unknown;
}): boolean {
const fragments = [
...collectResponsesErrorFragments(input.payload),
];
const rawErrText = asTrimmedString(input.rawErrText);
if (rawErrText) fragments.push(rawErrText);
const combined = fragments.join(' ').toLowerCase();
if (!combined) return false;
return (
combined.includes('previous_response_not_found')
|| /previous[\s_-]*response(?:[\s_-]*(?:id|identifier))?[\s_-]*not[\s_-]*found/i.test(combined)
);
}
export function extractResponsesTerminalResponseId(payload: unknown): string | null {
if (!isRecord(payload)) return null;
const type = asTrimmedString(payload.type).toLowerCase();
if (
type === 'response.completed'
|| type === 'response.failed'
|| type === 'response.incomplete'
) {
if (isRecord(payload.response)) {
const responseId = asTrimmedString(payload.response.id);
return responseId || null;
}
return null;
}
const object = asTrimmedString(payload.object).toLowerCase();
const status = asTrimmedString(payload.status).toLowerCase();
if (
(object === 'response' && RESPONSES_TERMINAL_STATUSES.has(status))
|| RESPONSES_TERMINAL_STATUSES.has(status)
) {
const responseId = asTrimmedString(payload.id);
return responseId || null;
}
if (isRecord(payload.response)) {
const nestedStatus = asTrimmedString(payload.response.status).toLowerCase();
if (RESPONSES_TERMINAL_STATUSES.has(nestedStatus)) {
const responseId = asTrimmedString(payload.response.id);
return responseId || null;
}
}
return null;
}
@@ -1234,6 +1234,12 @@ export function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>):
if (body.tools !== undefined) payload.tools = convertClaudeToolsToOpenAiChat(body.tools);
if (body.tool_choice !== undefined) payload.tool_choice = convertClaudeToolChoiceToOpenAiChat(body.tool_choice);
const promptCacheKey = asTrimmedString(body.prompt_cache_key);
if (promptCacheKey) payload.prompt_cache_key = promptCacheKey;
const previousResponseId = asTrimmedString(body.previous_response_id);
if (previousResponseId) payload.previous_response_id = previousResponseId;
return { model, stream, messages, payload };
}
+1
View File
@@ -342,6 +342,7 @@ export type RuntimeRoutingWeightsPayload = {
export type RuntimeSettingsPayload = {
proxyToken?: string;
systemProxyUrl?: string;
modelAvailabilityProbeEnabled?: boolean;
codexUpstreamWebsocketEnabled?: boolean;
disableCrossProtocolFallback?: boolean;
proxySessionChannelConcurrencyLimit?: number;
+8
View File
@@ -41,6 +41,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'按成本、延迟、成功率自动选择最优通道,故障自动转移': 'Automatically select the optimal channel based on cost, delay, and success rate, and automatically transfer failures',
'按模型过滤': 'Filter by model',
'按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)': 'Randomly by weight (W=${weight}, cost=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)}, same-site channel=${siteChannels}, probability≈${(probability * 100).toFixed(1)}%)',
'P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择': 'P value is a hard priority. Selection stays within the highest available priority tier, then chooses randomly using weight, cost, and health signals.',
'保存': 'save',
'保存 Cookie': 'Save Cookie',
'保存 Cookie 失败': 'Failed to save cookie',
@@ -573,6 +574,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'The system automatically generates routes based on model availability. Priorities are managed as P0/P1 buckets, and multiple channels can share the same bucket; drag channels or the gray separators to adjust them. Exact model routes automatically filter accounts and tokens that only support that model. Priority edits inside group routes write back to the source channels directly. The selection probability indicates the probability of the channel being selected when the request arrives. The priority of cost sources is: measured cost → account configuration cost → catalog reference price → default fallback unit price.',
'该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。': 'This group aggregates multiple source models into one public model name. Priority bucket edits here write back to the source channels directly. If a source model is reused by other groups, the impact is shown before saving.',
'通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。': 'Wildcard routes decide per request. The priority buckets below apply globally across the whole route, while source models are shown only as channel labels.',
'系统会根据模型可用性自动生成路由;精确模型路由会自动过滤只支持该模型的账号和令牌。': 'Routes are auto-generated by model availability; exact model routes auto-filter accounts and tokens that support that model.',
'系统设置': 'System settings',
'显示调试': 'show debugging',
'项目链接': 'Project link',
@@ -588,10 +590,16 @@ export const zhToEnSupplemental: Record<string, string> = {
'选择站点': 'Select site',
'选择账号': 'Select account',
'选择账号后同步站点令牌': 'Synchronize site token after selecting account',
'选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。': 'Selection probability explains where this request is more likely to land under the current strategy; for round robin and stable first it is better treated as an order hint.',
'先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位': 'Avoid recently failed or unhealthy sites first, then rotate through the stable pool in order; P value means rotation order.',
'讯飞星火': 'iFlytek Spark',
'延迟': 'Delay',
'验证': 'verify',
'验证 Token': 'Verify Token',
'只要更高优先级还有可用通道,后面的通道本次就不会参与选择。': 'As long as a higher-priority tier still has available channels, later channels will not participate in this selection.',
'忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却': 'Ignore P value and call in global order; after 3 consecutive failures the channel enters staged cooldown.',
'当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。': 'This strategy does not use P value; if you switch back to other strategies later, the saved drag order is still kept.',
'当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。': 'Under this strategy, stable sites rotate by P order; unstable sites are automatically downweighted or temporarily avoided.',
'验证成功: ${result.userInfo?.username || \'未知用户\'}': 'Verification successful: ${result.userInfo?.username || \'未知用户\'}',
'验证失败': 'Authentication failed',
'验证中...': 'Verifying...',
+8
View File
@@ -197,6 +197,14 @@ const zhToEn: Record<string, string> = {
'系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Priorities are managed as P0/P1 buckets, and multiple channels can share the same bucket; drag channels or the gray separators to adjust them. Exact model routes auto-filter accounts and tokens that support that model. Priority edits inside group routes write back to the source channels directly. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
'该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。': 'This group aggregates multiple source models into one public model name. Priority bucket edits here write back to the source channels directly. If a source model is reused by other groups, you will be warned about the impact before saving.',
'通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。': 'Wildcard routes decide per request. The priority buckets below apply globally across the whole route, while source models are shown only as channel labels.',
'系统会根据模型可用性自动生成路由;精确模型路由会自动过滤只支持该模型的账号和令牌。': 'Routes are auto-generated by model availability; exact model routes auto-filter accounts and tokens that support that model.',
'P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择': 'P value is a hard priority. Selection stays within the highest available priority tier, then chooses randomly using weight, cost, and health signals.',
'忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却': 'Ignore P value and call in global order; after 3 consecutive failures the channel enters staged cooldown.',
'先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位': 'Avoid recently failed or unhealthy sites first, then rotate through the stable pool in order; P value means rotation order.',
'当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。': 'This strategy does not use P value; if you switch back to other strategies later, the saved drag order is still kept.',
'当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。': 'Under this strategy, stable sites rotate by P order; unstable sites are automatically downweighted or temporarily avoided.',
'只要更高优先级还有可用通道,后面的通道本次就不会参与选择。': 'As long as a higher-priority tier still has available channels, later channels will not participate in this selection.',
'选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。': 'Selection probability explains where this request is more likely to land under the current strategy; for round robin and stable first it is better treated as an order hint.',
'代理端点': 'Proxy Endpoints',
'路由行为': 'Routing Behavior',
'指标口径': 'Metric Notes',
+98
View File
@@ -8,6 +8,7 @@ import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import ModernSelect from '../components/ModernSelect.js';
import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js';
import FactoryResetModal from './settings/FactoryResetModal.js';
import ModelAvailabilityProbeConfirmModal from './settings/ModelAvailabilityProbeConfirmModal.js';
import RouteSelectorModal from './settings/RouteSelectorModal.js';
import UpdateCenterSection from './settings/UpdateCenterSection.js';
import {
@@ -28,6 +29,7 @@ import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
const PROXY_TOKEN_PREFIX = 'sk-';
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
const FACTORY_RESET_CONFIRM_SECONDS = 3;
const MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT = '我确认我使用的中转站全部允许批量测活,如因开启此功能被中转站封号,自行负责。';
const SECONDS_PER_DAY = 24 * 60 * 60;
const ROUTE_COOLDOWN_UNIT_OPTIONS = [
{ value: 'second', label: '秒', multiplierSec: 1 },
@@ -58,6 +60,7 @@ type RuntimeSettings = {
logCleanupUsageLogsEnabled: boolean;
logCleanupProgramLogsEnabled: boolean;
logCleanupRetentionDays: number;
modelAvailabilityProbeEnabled: boolean;
codexUpstreamWebsocketEnabled: boolean;
disableCrossProtocolFallback: boolean;
proxySessionChannelConcurrencyLimit: number;
@@ -235,6 +238,7 @@ export default function Settings() {
logCleanupUsageLogsEnabled: false,
logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30,
modelAvailabilityProbeEnabled: false,
codexUpstreamWebsocketEnabled: false,
disableCrossProtocolFallback: false,
proxySessionChannelConcurrencyLimit: 2,
@@ -255,6 +259,7 @@ export default function Settings() {
const [testingCheckin, setTestingCheckin] = useState(false);
const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingModelAvailabilityProbe, setSavingModelAvailabilityProbe] = useState(false);
const [savingProxyTransport, setSavingProxyTransport] = useState(false);
const [testingSystemProxy, setTestingSystemProxy] = useState(false);
const [systemProxyTestState, setSystemProxyTestState] = useState<SystemProxyTestState>(null);
@@ -300,6 +305,10 @@ export default function Settings() {
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
const [selectorOpen, setSelectorOpen] = useState(false);
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
const [modelAvailabilityProbeConfirmOpen, setModelAvailabilityProbeConfirmOpen] = useState(false);
const modelAvailabilityProbeConfirmPresence = useAnimatedVisibility(modelAvailabilityProbeConfirmOpen, 220);
const [modelAvailabilityProbeConfirmationInput, setModelAvailabilityProbeConfirmationInput] = useState('');
const [savedModelAvailabilityProbeEnabled, setSavedModelAvailabilityProbeEnabled] = useState(false);
const [factoryResetOpen, setFactoryResetOpen] = useState(false);
const factoryResetPresence = useAnimatedVisibility(factoryResetOpen, 220);
const [factoryResetting, setFactoryResetting] = useState(false);
@@ -377,6 +386,12 @@ export default function Settings() {
}));
}, [migrationDialect]);
useEffect(() => {
if (!modelAvailabilityProbeConfirmOpen) {
setModelAvailabilityProbeConfirmationInput('');
}
}, [modelAvailabilityProbeConfirmOpen]);
useEffect(() => {
if (!factoryResetOpen) {
setFactoryResetSecondsLeft(FACTORY_RESET_CONFIRM_SECONDS);
@@ -469,6 +484,7 @@ export default function Settings() {
logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
: 30,
modelAvailabilityProbeEnabled: !!runtimeInfo.modelAvailabilityProbeEnabled,
codexUpstreamWebsocketEnabled: !!runtimeInfo.codexUpstreamWebsocketEnabled,
disableCrossProtocolFallback: !!runtimeInfo.disableCrossProtocolFallback,
proxySessionChannelConcurrencyLimit: Number(runtimeInfo.proxySessionChannelConcurrencyLimit) >= 0
@@ -499,6 +515,7 @@ export default function Settings() {
globalBlockedBrands: Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : [],
globalAllowedModels: Array.isArray(runtimeInfo.globalAllowedModels) ? runtimeInfo.globalAllowedModels : [],
});
setSavedModelAvailabilityProbeEnabled(!!runtimeInfo.modelAvailabilityProbeEnabled);
setBlockedBrands(Array.isArray(runtimeInfo.globalBlockedBrands) ? runtimeInfo.globalBlockedBrands : []);
setAllowedModels(Array.isArray(runtimeInfo.globalAllowedModels) ? runtimeInfo.globalAllowedModels : []);
setProxyErrorKeywordsText(
@@ -646,6 +663,42 @@ export default function Settings() {
}
};
const persistModelAvailabilityProbeSetting = async (enabled: boolean) => {
setSavingModelAvailabilityProbe(true);
try {
const res = await api.updateRuntimeSettings({
modelAvailabilityProbeEnabled: enabled,
});
const nextEnabled = typeof res?.modelAvailabilityProbeEnabled === 'boolean'
? res.modelAvailabilityProbeEnabled
: enabled;
setRuntime((prev) => ({
...prev,
modelAvailabilityProbeEnabled: nextEnabled,
}));
setSavedModelAvailabilityProbeEnabled(nextEnabled);
setModelAvailabilityProbeConfirmOpen(false);
setModelAvailabilityProbeConfirmationInput('');
toast.success(nextEnabled ? '批量测活已开启' : '批量测活已关闭');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingModelAvailabilityProbe(false);
}
};
const saveModelAvailabilityProbeSettings = async () => {
if (runtime.modelAvailabilityProbeEnabled === savedModelAvailabilityProbeEnabled) {
toast.info('批量测活设置未变化');
return;
}
if (runtime.modelAvailabilityProbeEnabled) {
setModelAvailabilityProbeConfirmOpen(true);
return;
}
await persistModelAvailabilityProbeSetting(false);
};
const saveProxyTransportSettings = async () => {
setSavingProxyTransport(true);
try {
@@ -1009,6 +1062,16 @@ export default function Settings() {
setFactoryResetOpen(false);
};
const closeModelAvailabilityProbeConfirmModal = () => {
if (savingModelAvailabilityProbe) return;
setModelAvailabilityProbeConfirmOpen(false);
};
const handleConfirmModelAvailabilityProbe = async () => {
if (modelAvailabilityProbeConfirmationInput.trim() !== MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT) return;
await persistModelAvailabilityProbeSetting(true);
};
const handleFactoryReset = async () => {
if (factoryResetSecondsLeft > 0 || factoryResetting) return;
setFactoryResetting(true);
@@ -1417,6 +1480,32 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 24%, var(--color-border))' }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8, color: 'var(--color-danger)' }}>批量测活</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.8 }}>
默认关闭。开启后,metapi 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
</div>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8, marginBottom: 12 }}>
只有在你确认自己使用的中转站明确允许批量测活时才应该开启。若上游不允许,这类探测可能带来封号或风控风险。
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 12 }}>
<input
type="checkbox"
checked={runtime.modelAvailabilityProbeEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, modelAvailabilityProbeEnabled: e.target.checked }))}
/>
允许 metapi 后台主动批量测活
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7, marginBottom: 12 }}>
当前状态:{savedModelAvailabilityProbeEnabled ? '已启用' : '已关闭'}。首次开启必须手动输入确认语句。
</div>
<div>
<button onClick={saveModelAvailabilityProbeSettings} disabled={savingModelAvailabilityProbe} className="btn btn-primary">
{savingModelAvailabilityProbe ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存批量测活设置'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
@@ -2108,6 +2197,15 @@ export default function Settings() {
onClose={closeFactoryResetModal}
onConfirm={handleFactoryReset}
/>
<ModelAvailabilityProbeConfirmModal
presence={modelAvailabilityProbeConfirmPresence}
confirmText={MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT}
confirmationInput={modelAvailabilityProbeConfirmationInput}
saving={savingModelAvailabilityProbe}
onConfirmationInputChange={setModelAvailabilityProbeConfirmationInput}
onClose={closeModelAvailabilityProbeConfirmModal}
onConfirm={handleConfirmModelAvailabilityProbe}
/>
<RouteSelectorModal
presence={selectorModalPresence}
loading={selectorLoading}
+8 -2
View File
@@ -25,6 +25,7 @@ import {
import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js';
import {
getRouteRoutingStrategyDescription,
getRouteRoutingStrategyLabel,
normalizeRouteRoutingStrategyValue,
} from './token-routes/routingStrategy.js';
@@ -1565,8 +1566,13 @@ export default function TokenRoutes() {
/>
{/* Info tip */}
<div className="info-tip" style={{ marginBottom: 12 }}>
{tr('系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}
<div className="info-tip" style={{ marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div>{tr('系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}</div>
<div>{`${getRouteRoutingStrategyLabel('weighted')}:${getRouteRoutingStrategyDescription('weighted')}`}</div>
<div>{`${getRouteRoutingStrategyLabel('round_robin')}:${getRouteRoutingStrategyDescription('round_robin')}`}</div>
<div>{`${getRouteRoutingStrategyLabel('stable_first')}:${getRouteRoutingStrategyDescription('stable_first')}`}</div>
<div>{tr('选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。')}</div>
<div>{tr('成本来源优先级:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}</div>
</div>
{/* Manual route panel */}
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Settings from './Settings.js';
const MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT = '我确认我使用的中转站全部允许批量测活,如因开启此功能被中转站封号,自行负责。';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
getBrandList: vi.fn(),
updateRuntimeSettings: vi.fn(),
getModelTokenCandidates: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: () => null,
InlineBrandIcon: () => null,
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Settings model availability probe confirmation', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
checkinScheduleMode: 'interval',
checkinIntervalHours: 6,
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
modelAvailabilityProbeEnabled: false,
routingFallbackUnitCost: 1,
routingWeights: {},
adminIpAllowlist: [],
systemProxyUrl: '',
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getBrandList.mockResolvedValue({ brands: [] });
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
restartRequired: false,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
modelAvailabilityProbeEnabled: true,
});
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
});
afterEach(() => {
vi.clearAllMocks();
});
it('requires the exact confirmation text before enabling model availability probing', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const toggleLabel = root.root.find((node) => (
node.type === 'label'
&& collectText(node).includes('允许 metapi 后台主动批量测活')
));
const toggle = toggleLabel.findByType('input');
expect(toggle.props.checked).toBe(false);
await act(async () => {
toggle.props.onChange({ target: { checked: true } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存批量测活设置'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(JSON.stringify(root.toJSON())).toContain(MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT);
expect(apiMock.updateRuntimeSettings).not.toHaveBeenCalled();
const confirmButtonBeforeTyping = root.root.find((node) => (
node.type === 'button'
&& collectText(node).trim() === '确认开启批量测活'
&& node.props.className === 'btn btn-danger'
));
expect(confirmButtonBeforeTyping.props.disabled).toBe(true);
const confirmInput = root.root.find((node) => (
node.type === 'textarea'
&& node.props.placeholder === '请输入上方确认语句'
));
await act(async () => {
confirmInput.props.onChange({ target: { value: MODEL_AVAILABILITY_PROBE_CONFIRM_TEXT } });
});
const confirmButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).trim() === '确认开启批量测活'
&& node.props.className === 'btn btn-danger'
));
expect(confirmButton.props.disabled).toBe(false);
await act(async () => {
confirmButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
modelAvailabilityProbeEnabled: true,
});
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,105 @@
import React from 'react';
import { createPortal } from 'react-dom';
type ModalPresence = {
shouldRender: boolean;
isVisible: boolean;
};
type ModelAvailabilityProbeConfirmModalProps = {
presence: ModalPresence;
confirmText: string;
confirmationInput: string;
saving: boolean;
onConfirmationInputChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
};
export default function ModelAvailabilityProbeConfirmModal({
presence,
confirmText,
confirmationInput,
saving,
onConfirmationInputChange,
onClose,
onConfirm,
}: ModelAvailabilityProbeConfirmModalProps) {
if (!presence.shouldRender) {
return null;
}
const canConfirm = confirmationInput.trim() === confirmText;
const titleId = 'model-availability-probe-confirm-modal-title';
const descriptionId = 'model-availability-probe-confirm-modal-description';
const handleRequestClose = () => {
if (saving) return;
onClose();
};
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={handleRequestClose}>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 760, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
aria-busy={saving}
onClick={(e) => e.stopPropagation()}
>
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
开启后,metapi 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
请手动输入以下整句后再开启:
</div>
<div
style={{
padding: 12,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.8,
}}
>
{confirmText}
</div>
<textarea
value={confirmationInput}
onChange={(e) => onConfirmationInputChange(e.target.value)}
placeholder="请输入上方确认语句"
spellCheck={false}
style={{
width: '100%',
minHeight: 96,
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
resize: 'vertical',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</div>
<div className="modal-footer">
<button onClick={handleRequestClose} disabled={saving} className="btn btn-ghost">取消</button>
<button onClick={onConfirm} disabled={saving || !canConfirm} className="btn btn-danger">
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 确认开启中...</>
: '确认开启批量测活'}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+20 -4
View File
@@ -34,6 +34,8 @@ import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModel
import { SortableChannelRow } from './SortableChannelRow.js';
import {
getRouteRoutingStrategyLabel,
getRouteRoutingStrategyDescription,
getRouteRoutingStrategyHint,
normalizeRouteRoutingStrategyValue,
} from './routingStrategy.js';
import {
@@ -199,6 +201,8 @@ function RouteCardInner({
const channelManagementDisabled = explicitGroupRoute;
const title = resolveRouteTitle(route);
const routingStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy);
const routingStrategyDescription = getRouteRoutingStrategyDescription(routingStrategy);
const routingStrategyHint = getRouteRoutingStrategyHint(routingStrategy);
const hasCachedDecisionSnapshot = !!route.decisionSnapshot;
const cachedDecisionTooltip = route.decisionRefreshedAt
? `${tr('最近刷新')}: ${formatDateTimeMinuteLocal(route.decisionRefreshedAt)}`
@@ -207,17 +211,17 @@ function RouteCardInner({
{
value: 'weighted',
label: tr('权重随机'),
description: tr('按优先级、权重和成本信号综合选择'),
description: getRouteRoutingStrategyDescription('weighted'),
},
{
value: 'round_robin',
label: tr('轮询'),
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
description: getRouteRoutingStrategyDescription('round_robin'),
},
{
value: 'stable_first',
label: tr('稳定优先'),
description: tr('按优先级优先选择当前最稳、最快、成功率更高的通道,不做随机分流'),
description: getRouteRoutingStrategyDescription('stable_first'),
},
] as const;
@@ -359,7 +363,11 @@ function RouteCardInner({
{tr('0 通道')}
</span>
) : (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
<span
className="badge badge-muted"
style={{ fontSize: 10, flexShrink: 0 }}
data-tooltip={`${getRouteRoutingStrategyLabel(routingStrategy)}:${routingStrategyDescription}`}
>
{getRouteRoutingStrategyLabel(routingStrategy)}
</span>
)}
@@ -545,6 +553,14 @@ function RouteCardInner({
placeholder={tr('选择路由策略')}
emptyLabel={tr('暂无可选策略')}
/>
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 12, lineHeight: 1.55, color: 'var(--color-text-secondary)' }}>
{routingStrategyDescription}
</div>
<div style={{ fontSize: 11, lineHeight: 1.5, color: 'var(--color-text-muted)' }}>
{routingStrategyHint}
</div>
</div>
</div>
</div>
)}
@@ -12,3 +12,25 @@ export function getRouteRoutingStrategyLabel(value?: RouteRoutingStrategy | null
if (strategy === 'stable_first') return tr('稳定优先');
return tr('权重随机');
}
export function getRouteRoutingStrategyDescription(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') {
return tr('忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却');
}
if (strategy === 'stable_first') {
return tr('先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位');
}
return tr('P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择');
}
export function getRouteRoutingStrategyHint(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') {
return tr('当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。');
}
if (strategy === 'stable_first') {
return tr('当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。');
}
return tr('只要更高优先级还有可用通道,后面的通道本次就不会参与选择。');
}
@@ -50,7 +50,7 @@ describe('TokenRoutes routing strategy updates', () => {
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutesSummary
.mockResolvedValueOnce([
.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
@@ -65,8 +65,7 @@ describe('TokenRoutes routing strategy updates', () => {
decisionSnapshot: null,
decisionRefreshedAt: null,
},
])
.mockRejectedValueOnce(new Error('refresh failed'));
]);
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
@@ -101,11 +100,13 @@ describe('TokenRoutes routing strategy updates', () => {
});
await flushMicrotasks();
apiMock.getRoutesSummary.mockRejectedValueOnce(new Error('refresh failed'));
const roundRobinOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('轮询')
&& collectText(node).startsWith('轮询')
));
await act(async () => {
@@ -151,11 +152,13 @@ describe('TokenRoutes routing strategy updates', () => {
});
await flushMicrotasks();
apiMock.getRoutesSummary.mockRejectedValueOnce(new Error('refresh failed'));
const stableFirstOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('稳定优先')
&& collectText(node).startsWith('稳定优先')
));
await act(async () => {
@@ -169,7 +172,7 @@ describe('TokenRoutes routing strategy updates', () => {
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
&& collectText(node).includes('稳定优先')
&& collectText(node).startsWith('稳定优先')
));
expect(collectText(strategyTrigger)).toContain('稳定优先');
} finally {