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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user