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