fix: stabilize Codex continuation and proxy retry recovery / 修复 Codex 续传与代理重试稳定性 (#257)

* 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

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
This commit is contained in:
Bainily
2026-03-23 19:17:22 +08:00
committed by GitHub
parent 5be5e2eb07
commit 1449f08e6c
36 changed files with 1802 additions and 656 deletions
+23 -8
View File
@@ -41,6 +41,7 @@ import {
} from '../../routes/proxy/geminiCliCompat.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
import {
createSurfaceFailureToolkit,
createSurfaceDispatchRequest,
@@ -49,8 +50,6 @@ import {
trySurfaceOauthRefreshRecovery,
} from './sharedSurface.js';
const MAX_RETRIES = 2;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
@@ -59,6 +58,17 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function prioritizeEndpointCandidate(
candidates: Array<'chat' | 'messages' | 'responses'>,
preferred: 'chat' | 'messages' | 'responses',
): Array<'chat' | 'messages' | 'responses'> {
if (!candidates.includes(preferred)) return candidates;
return [
preferred,
...candidates.filter((candidate) => candidate !== preferred),
];
}
export async function handleChatSurfaceRequest(
request: FastifyRequest,
reply: FastifyReply,
@@ -108,10 +118,11 @@ export async function handleChatSurfaceRequest(
proxyToken: getProxyAuthContext(request)?.token || null,
});
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const maxRetries = getProxyMaxChannelRetries();
const failureToolkit = createSurfaceFailureToolkit({
warningScope: 'chat',
downstreamPath,
maxRetries: MAX_RETRIES,
maxRetries,
clientContext,
downstreamApiKeyId,
});
@@ -119,7 +130,7 @@ export async function handleChatSurfaceRequest(
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
while (retryCount <= maxRetries) {
const selected = await selectSurfaceChannelForAttempt({
requestedModel,
downstreamPolicy,
@@ -142,7 +153,7 @@ export async function handleChatSurfaceRequest(
const modelName = selected.actualModel || requestedModel;
const oauth = getOauthInfoFromAccount(selected.account);
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
const endpointCandidates = [
let endpointCandidates = [
...await resolveUpstreamEndpointCandidates(
{
site: selected.site,
@@ -157,6 +168,9 @@ export async function handleChatSurfaceRequest(
},
),
];
if (oauth?.provider === 'codex' && downstreamFormat === 'openai') {
endpointCandidates = prioritizeEndpointCandidate(endpointCandidates, 'responses');
}
const endpointRuntimeContext = {
siteId: selected.site.id,
modelName,
@@ -624,17 +638,18 @@ export async function handleClaudeCountTokensSurfaceRequest(
});
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const maxRetries = getProxyMaxChannelRetries();
const failureToolkit = createSurfaceFailureToolkit({
warningScope: 'chat',
downstreamPath,
maxRetries: MAX_RETRIES,
maxRetries,
clientContext,
downstreamApiKeyId,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
while (retryCount <= maxRetries) {
const selected = await selectSurfaceChannelForAttempt({
requestedModel,
downstreamPolicy,
@@ -664,7 +679,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
requestedModel,
);
if (!endpointCandidates.includes('messages')) {
if (retryCount < MAX_RETRIES) {
if (canRetryProxyChannel(retryCount)) {
retryCount += 1;
continue;
}
+28 -15
View File
@@ -36,8 +36,7 @@ import { detectDownstreamClientContext, type DownstreamClientContext } from '../
import { insertProxyLog } from '../../services/proxyLogStore.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
import { readRuntimeResponseText } from '../executors/types.js';
const MAX_RETRIES = 2;
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
const GEMINI_MODEL_PROBES = [
'gemini-2.5-flash',
'gemini-2.0-flash',
@@ -253,6 +252,18 @@ async function logProxy(
}
}
async function recordGeminiChannelSuccessBestEffort(
channelId: number,
latencyMs: number,
modelName: string,
): Promise<void> {
try {
await tokenRouter.recordSuccess?.(channelId, latencyMs, 0, modelName);
} catch (error) {
console.warn('[proxy/gemini] failed to record channel success', error);
}
}
export async function geminiProxyRoute(app: FastifyInstance) {
const listModels = async (request: FastifyRequest, reply: FastifyReply) => {
const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion(
@@ -264,7 +275,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
let lastText = 'No available channels for Gemini models';
let lastContentType = 'application/json';
while (retryCount <= MAX_RETRIES) {
while (retryCount <= getProxyMaxChannelRetries()) {
const selected = retryCount === 0
? await selectGeminiChannel(request)
: await selectNextGeminiProbeChannel(request, excludeChannelIds);
@@ -305,10 +316,11 @@ export async function geminiProxyRoute(app: FastifyInstance) {
status: upstream.status,
errorText: text,
});
if (retryCount < MAX_RETRIES) {
if (canRetryProxyChannel(retryCount)) {
retryCount += 1;
continue;
}
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
try {
@@ -330,10 +342,11 @@ export async function geminiProxyRoute(app: FastifyInstance) {
type: 'upstream_error',
},
});
if (retryCount < MAX_RETRIES) {
if (canRetryProxyChannel(retryCount)) {
retryCount += 1;
continue;
}
return reply.code(lastStatus).type(lastContentType).send(lastText);
}
}
};
@@ -392,7 +405,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
let lastText = 'No available channels for this model';
let lastContentType = 'application/json';
while (retryCount <= MAX_RETRIES) {
while (retryCount <= getProxyMaxChannelRetries()) {
const selected = retryCount === 0
? await tokenRouter.selectChannel(requestedModel, policy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy);
@@ -429,7 +442,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
status: 500,
errorText: 'Gemini CLI OAuth project is missing',
});
if (retryCount < MAX_RETRIES) {
if (canRetryProxyChannel(retryCount)) {
retryCount += 1;
continue;
}
@@ -550,7 +563,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
upstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
if (canRetryProxyChannel(retryCount)) {
retryCount += 1;
continue;
}
@@ -572,7 +585,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
: upstreamReader;
if (!reader) {
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel);
await logProxy(
selected,
requestedModel,
@@ -622,7 +635,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
}
const parsedUsage = parseProxyUsage(aggregateState);
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel);
await logProxy(
selected,
requestedModel,
@@ -658,7 +671,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
);
parsedUsage = parseProxyUsage(aggregateState);
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel);
await logProxy(
selected,
requestedModel,
@@ -681,7 +694,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
);
} catch {
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel);
await logProxy(
selected,
requestedModel,
@@ -843,7 +856,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
null,
clientContext,
);
if (retryCount < MAX_RETRIES) {
if (canRetryProxyChannel(retryCount)) {
retryCount += 1;
continue;
}
@@ -868,7 +881,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
},
});
const latency = Date.now() - startTime;
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
await recordGeminiChannelSuccessBestEffort(selected.channel.id, latency, actualModel);
await logProxy(
selected,
requestedModel,
@@ -920,7 +933,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
upstreamPath || null,
clientContext,
);
if (retryCount < MAX_RETRIES) {
if (canRetryProxyChannel(retryCount)) {
retryCount += 1;
continue;
}
@@ -38,6 +38,7 @@ import {
summarizeConversationFileInputsInResponsesBody,
} from '../capabilities/conversationFileCapabilities.js';
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
import {
createSurfaceFailureToolkit,
createSurfaceDispatchRequest,
@@ -46,8 +47,6 @@ import {
trySurfaceOauthRefreshRecovery,
} from './sharedSurface.js';
const MAX_RETRIES = 2;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -178,17 +177,18 @@ export async function handleOpenAiResponsesSurfaceRequest(
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const maxRetries = getProxyMaxChannelRetries();
const failureToolkit = createSurfaceFailureToolkit({
warningScope: 'responses',
downstreamPath,
maxRetries: MAX_RETRIES,
maxRetries,
clientContext,
downstreamApiKeyId,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
while (retryCount <= maxRetries) {
const selected = await selectSurfaceChannelForAttempt({
requestedModel,
downstreamPolicy,
@@ -690,6 +690,13 @@ describe('selectSurfaceChannelForAttempt', () => {
headers: { authorization: 'Bearer new-access-token' },
}), 'https://upstream.example.com/v1/responses');
expect(result).toEqual({
request: {
endpoint: 'responses',
path: '/v1/responses',
headers: { authorization: 'Bearer new-access-token' },
body: { model: 'gpt-5.2' },
},
targetUrl: 'https://upstream.example.com/v1/responses',
upstream: refreshedResponse,
upstreamPath: '/v1/responses',
});
@@ -222,6 +222,8 @@ export async function trySurfaceOauthRefreshRecovery<TRequest extends BuiltEndpo
}): Promise<{
upstream: Awaited<ReturnType<typeof dispatchRuntimeRequest>>;
upstreamPath: string;
request?: TRequest;
targetUrl?: string;
} | null> {
try {
const refreshed = await refreshOauthAccessTokenSingleflight(input.selected.account.id);
@@ -239,6 +241,8 @@ export async function trySurfaceOauthRefreshRecovery<TRequest extends BuiltEndpo
return {
upstream: refreshedResponse,
upstreamPath: refreshedRequest.path,
request: refreshedRequest,
targetUrl: refreshedTargetUrl,
};
}