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
@@ -320,6 +320,123 @@ describe('sanitizeResponsesBodyForProxy', () => {
});
expect(result.max_completion_tokens).toBeUndefined();
});
it('normalizes failed input item statuses before proxying Responses requests', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: [
{
role: 'assistant',
status: 'failed',
content: [
{
type: 'output_text',
text: 'tool step failed',
},
],
},
{
type: 'function_call',
call_id: 'call_1',
name: 'lookup_weather',
arguments: '{"city":"Shanghai"}',
status: 'failed',
},
{
type: 'function_call_output',
call_id: 'call_1',
output: '{"error":"timeout"}',
status: 'completed',
},
],
},
'gpt-5',
false,
);
expect(result.input).toEqual([
{
type: 'message',
role: 'assistant',
status: 'incomplete',
content: [
{
type: 'output_text',
text: 'tool step failed',
},
],
},
{
type: 'function_call',
call_id: 'call_1',
name: 'lookup_weather',
arguments: '{"city":"Shanghai"}',
status: 'incomplete',
},
{
type: 'function_call_output',
call_id: 'call_1',
output: '{"error":"timeout"}',
status: 'completed',
},
]);
});
it('drops unsupported input item statuses before proxying Responses requests', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: [
{
type: 'message',
role: 'user',
status: 'errored',
content: 'hello',
},
{
type: 'function_call',
call_id: 'call_2',
name: 'lookup_weather',
arguments: '{}',
status: 'invalid',
},
{
type: 'reasoning',
id: 'rs_1',
status: 'broken',
summary: [],
},
],
},
'gpt-5',
false,
);
expect(result.input).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: 'hello',
},
],
},
{
type: 'function_call',
call_id: 'call_2',
name: 'lookup_weather',
arguments: '{}',
},
{
type: 'reasoning',
id: 'rs_1',
summary: [],
},
]);
});
});
describe('convertOpenAiBodyToResponsesBody', () => {
@@ -8,6 +8,36 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
const ALLOWED_RESPONSES_INPUT_STATUSES = new Set([
'in_progress',
'completed',
'incomplete',
]);
function normalizeResponsesInputStatus(value: unknown): string | undefined {
const normalized = asTrimmedString(value).toLowerCase();
if (!normalized) return undefined;
if (normalized === 'failed') return 'incomplete';
return ALLOWED_RESPONSES_INPUT_STATUSES.has(normalized) ? normalized : undefined;
}
function withNormalizedResponsesInputStatus(item: Record<string, unknown>): Record<string, unknown> {
const normalizedStatus = normalizeResponsesInputStatus(item.status);
if (normalizedStatus) {
return {
...item,
status: normalizedStatus,
};
}
if (!Object.prototype.hasOwnProperty.call(item, 'status')) {
return item;
}
const { status: _status, ...rest } = item;
return rest;
}
function firstNonEmptyTrimmedString(...values: unknown[]): string {
for (const value of values) {
const normalized = asTrimmedString(value);
@@ -139,7 +169,7 @@ function toResponsesInputMessageFromText(text: string): Record<string, unknown>
export function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'function_call' || type === 'function_call_output') {
return item;
return withNormalizedResponsesInputStatus(item);
}
const role = asTrimmedString(item.role).toLowerCase() || 'user';
@@ -149,20 +179,20 @@ export function normalizeResponsesMessageItem(item: Record<string, unknown>): Re
);
if (type === 'message') {
return {
return withNormalizedResponsesInputStatus({
...item,
role,
content: normalizedContent,
};
});
}
if (asTrimmedString(item.role)) {
return {
return withNormalizedResponsesInputStatus({
...item,
type: 'message',
role,
content: normalizedContent,
};
});
}
if (typeof item.content === 'string' || typeof item.text === 'string') {
@@ -170,7 +200,7 @@ export function normalizeResponsesMessageItem(item: Record<string, unknown>): Re
return text ? toResponsesInputMessageFromText(text) : item;
}
return item;
return withNormalizedResponsesInputStatus(item);
}
export function normalizeResponsesInputForCompatibility(input: unknown): unknown {
@@ -23,6 +23,8 @@ type EndpointAttemptContext = {
type EndpointRecoverResult = {
upstream: UndiciResponse;
upstreamPath: string;
request?: CompatibilityRequest;
targetUrl?: string;
} | null;
type CompatibilityRequest = {
@@ -70,6 +72,7 @@ export function createChatEndpointStrategy(input: CreateChatEndpointStrategyInpu
return {
upstream: normalizedResponse,
upstreamPath: normalizedClaudeRequest.path,
request: normalizedClaudeRequest,
};
}
@@ -104,6 +107,8 @@ export function createChatEndpointStrategy(input: CreateChatEndpointStrategyInpu
return {
upstream: minimalResponse,
upstreamPath: minimalRequest.path,
request: minimalRequest,
targetUrl: ctx.targetUrl,
};
}