Skip to content

Commit 87e1b9c

Browse files
committed
fix(providers): restrict gateway-incompatible mapping to anthropic wire (#158)
The "not implemented" → PROVIDER_GATEWAY_INCOMPATIBLE remap (and the matching retry short-circuit) only makes sense for Anthropic-compatible endpoints — the actionable hint tells the user to switch wire to openai-chat, which is misleading when the user is already on an OpenAI/Google wire and just hit a transient 501. Gate both call sites on wire === 'anthropic': non-anthropic wires fall through to the generic PROVIDER_ERROR path and keep retrying 5xx normally. Adds counter-example tests on openai-chat / openai-responses. Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent b45e91b commit 87e1b9c

6 files changed

Lines changed: 67 additions & 19 deletions

File tree

packages/core/src/agent.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,7 @@ export async function generateViaAgent(
883883
ms: Date.now() - sendStart,
884884
errorClass: err instanceof Error ? err.constructor.name : typeof err,
885885
});
886-
throw remapProviderError(err, input.model.provider);
886+
throw remapProviderError(err, input.model.provider, input.wire);
887887
}
888888

889889
const finalAssistant = findFinalAssistantMessage(agent.state.messages);
@@ -913,6 +913,7 @@ export async function generateViaAgent(
913913
throw remapProviderError(
914914
new CodesignError(message, ERROR_CODES.PROVIDER_ERROR),
915915
input.model.provider,
916+
input.wire,
916917
);
917918
}
918919
log.info('[generate] step=send_request.ok', { ...ctx, ms: Date.now() - sendStart });

packages/core/src/errors.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,20 +83,39 @@ describe('remapProviderError', () => {
8383
expect(out).toBe(err);
8484
});
8585

86-
it('tags 5xx "not implemented" bodies as PROVIDER_GATEWAY_INCOMPATIBLE', () => {
86+
it('tags 5xx "not implemented" bodies as PROVIDER_GATEWAY_INCOMPATIBLE on anthropic wire', () => {
8787
const err = httpError(500, 'not implemented');
88-
const out = remapProviderError(err, 'anthropic');
88+
const out = remapProviderError(err, 'anthropic', 'anthropic');
8989
expect(out).toBeInstanceOf(CodesignError);
9090
expect((out as CodesignError).code).toBe('PROVIDER_GATEWAY_INCOMPATIBLE');
9191
expect((out as CodesignError).message).toContain('not implemented');
9292
});
9393

94-
it('tags status-less errors whose message mentions 501 as PROVIDER_GATEWAY_INCOMPATIBLE', () => {
95-
const out = remapProviderError(new Error('HTTP 501 from gateway'), 'anthropic');
94+
it('tags status-less errors whose message mentions 501 as PROVIDER_GATEWAY_INCOMPATIBLE on anthropic wire', () => {
95+
const out = remapProviderError(new Error('HTTP 501 from gateway'), 'anthropic', 'anthropic');
9696
expect(out).toBeInstanceOf(CodesignError);
9797
expect((out as CodesignError).code).toBe('PROVIDER_GATEWAY_INCOMPATIBLE');
9898
});
9999

100+
it('does NOT remap 5xx "not implemented" to gateway-incompatible on openai-chat wire', () => {
101+
const err = httpError(501, 'not implemented');
102+
const out = remapProviderError(err, 'openai', 'openai-chat');
103+
// Non-anthropic wire: 501 is just a generic upstream error, pass through.
104+
expect(out).toBe(err);
105+
});
106+
107+
it('does NOT remap 501 on openai-responses wire even when body matches gateway pattern', () => {
108+
const err = httpError(501, 'messages api not supported');
109+
const out = remapProviderError(err, 'openai', 'openai-responses');
110+
expect(out).toBe(err);
111+
});
112+
113+
it('does NOT remap when wire is undefined (safer default: pass through)', () => {
114+
const err = httpError(500, 'not implemented');
115+
const out = remapProviderError(err, 'anthropic');
116+
expect(out).toBe(err);
117+
});
118+
100119
it('extracts status code from CodesignError messages that embed it', () => {
101120
const err = new CodesignError(
102121
'HTTP 401 — see https://platform.openai.com/account/api-keys',

packages/core/src/errors.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717

1818
import { looksLikeGatewayMissingMessagesApi } from '@open-codesign/providers';
19-
import type { ProviderId } from '@open-codesign/shared';
19+
import type { ProviderId, WireApi } from '@open-codesign/shared';
2020
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
2121

2222
export const PROVIDER_KEY_HELP_URL: Partial<Record<ProviderId, string>> = {
@@ -99,14 +99,22 @@ export function rewriteUpstreamMessage(
9999
* 4xx errors are rewritten — everything else is rethrown unchanged so the
100100
* retry/network layer keeps its own taxonomy.
101101
*/
102-
export function remapProviderError(err: unknown, provider: string | undefined): unknown {
102+
export function remapProviderError(
103+
err: unknown,
104+
provider: string | undefined,
105+
wire?: WireApi | undefined,
106+
): unknown {
103107
if (!(err instanceof Error)) return err;
104108
if (err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_ABORTED) return err;
105109
// Third-party Anthropic relays often reply to POST /v1/messages with 5xx +
106110
// "not implemented" while their /v1/models endpoint works. Catch that shape
107111
// before any other classification so the UI can suggest switching wire
108-
// instead of the misleading default "check your API key" message.
112+
// instead of the misleading default "check your API key" message. Guard on
113+
// wire === 'anthropic' because the actionable hint ("switch wire to
114+
// openai-chat") only makes sense for Anthropic-compatible endpoints — a 501
115+
// from an OpenAI/Google wire is just a generic upstream error.
109116
if (
117+
wire === 'anthropic' &&
110118
!(err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE) &&
111119
looksLikeGatewayMissingMessagesApi(err)
112120
) {

packages/core/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@ async function runModel(input: ModelRunInput): Promise<GenerateOutput> {
368368
...(input.onRetry !== undefined ? { onRetry: input.onRetry } : {}),
369369
logger: log,
370370
provider: input.model.provider,
371+
...(input.wire !== undefined ? { wire: input.wire } : {}),
371372
},
372373
complete,
373374
);
@@ -396,7 +397,7 @@ async function runModel(input: ModelRunInput): Promise<GenerateOutput> {
396397
reasoning = undefined;
397398
continue;
398399
}
399-
const remapped = remapProviderError(err, input.model.provider);
400+
const remapped = remapProviderError(err, input.model.provider, input.wire);
400401
log.error(`[${scope}] step=send_request.fail`, {
401402
...ctx,
402403
ms: Date.now() - sendStart,
@@ -808,6 +809,7 @@ export async function generateTitle(input: GenerateTitleInput): Promise<string>
808809
{
809810
logger: log,
810811
provider: input.model.provider,
812+
...(input.wire !== undefined ? { wire: input.wire } : {}),
811813
},
812814
);
813815
log.info('[title] step=send_request.ok', { ms: Date.now() - started });
@@ -821,6 +823,6 @@ export async function generateTitle(input: GenerateTitleInput): Promise<string>
821823
ms: Date.now() - started,
822824
errorClass: err instanceof Error ? err.constructor.name : typeof err,
823825
});
824-
throw remapProviderError(err, input.model.provider);
826+
throw remapProviderError(err, input.model.provider, input.wire);
825827
}
826828
}

packages/providers/src/retry.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,20 @@ describe('classifyError', () => {
2424
it('marks 5xx as retryable', () => {
2525
expect(classifyError(new HttpError('boom', 503))).toMatchObject({ retry: true });
2626
});
27-
it('does not retry 5xx when body says Messages API is not implemented', () => {
28-
const d = classifyError(new HttpError('500 not implemented', 500));
27+
it('does not retry 5xx when body says Messages API is not implemented on anthropic wire', () => {
28+
const d = classifyError(new HttpError('500 not implemented', 500), 'anthropic');
2929
expect(d.retry).toBe(false);
3030
expect(d.reason).toMatch(/gateway does not implement Messages API/);
3131
});
32+
it('still retries 5xx "not implemented" on openai-chat wire (not a gateway-compat issue)', () => {
33+
const d = classifyError(new HttpError('500 not implemented', 500), 'openai-chat');
34+
expect(d.retry).toBe(true);
35+
expect(d.reason).toMatch(/server error/);
36+
});
37+
it('still retries 5xx "not implemented" when wire is unknown (safer default)', () => {
38+
const d = classifyError(new HttpError('500 not implemented', 500));
39+
expect(d.retry).toBe(true);
40+
});
3241
it('marks 4xx (non-429) as non-retryable', () => {
3342
expect(classifyError(new HttpError('bad', 400))).toMatchObject({ retry: false });
3443
});

packages/providers/src/retry.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
* - any AbortSignal abort short-circuits immediately, no retry
1414
*/
1515

16-
import { type ChatMessage, CodesignError, ERROR_CODES, type ModelRef } from '@open-codesign/shared';
16+
import {
17+
type ChatMessage,
18+
CodesignError,
19+
ERROR_CODES,
20+
type ModelRef,
21+
type WireApi,
22+
} from '@open-codesign/shared';
1723
import { normalizeProviderError } from './errors';
1824
import { looksLikeGatewayMissingMessagesApi } from './gateway-compat';
1925
import { type GenerateOptions, type GenerateResult, complete } from './index';
@@ -32,6 +38,7 @@ export interface CompleteWithRetryOptions {
3238
onRetry?: (info: RetryReason) => void;
3339
logger?: { warn: (event: string, data?: Record<string, unknown>) => void };
3440
provider?: string;
41+
wire?: WireApi;
3542
}
3643

3744
const DEFAULT_MAX_RETRIES = 3;
@@ -51,7 +58,7 @@ const RETRYABLE_NET_CODES = new Set([
5158
'ECONNREFUSED',
5259
]);
5360

54-
function classifyByStatus(status: number, err: unknown): RetryDecision | undefined {
61+
function classifyByStatus(status: number, err: unknown, wire?: WireApi): RetryDecision | undefined {
5562
if (status === 429) {
5663
const retryAfterMs = extractRetryAfterMs(err);
5764
const decision: RetryDecision = { retry: true, reason: 'rate-limited (429)' };
@@ -63,8 +70,10 @@ function classifyByStatus(status: number, err: unknown): RetryDecision | undefin
6370
// return 5xx + "not implemented" for POST /v1/messages even though their
6471
// /v1/models endpoint works. Retrying wastes 3 rounds of exponential
6572
// backoff on an endpoint that will never respond; short-circuit so the
66-
// user sees the actionable error immediately.
67-
if (looksLikeGatewayMissingMessagesApi(err)) {
73+
// user sees the actionable error immediately. Only applies to
74+
// anthropic-wire endpoints — OpenAI/Google wires can emit the same text
75+
// for unrelated reasons and should retry normally.
76+
if (wire === 'anthropic' && looksLikeGatewayMissingMessagesApi(err)) {
6877
return { retry: false, reason: 'gateway does not implement Messages API' };
6978
}
7079
return { retry: true, reason: `server error (${status})` };
@@ -85,13 +94,13 @@ function classifyByNetwork(err: unknown): RetryDecision | undefined {
8594
return undefined;
8695
}
8796

88-
export function classifyError(err: unknown): RetryDecision {
97+
export function classifyError(err: unknown, wire?: WireApi): RetryDecision {
8998
if (err instanceof Error && (err.name === 'AbortError' || err.message === 'aborted')) {
9099
return { retry: false, reason: 'aborted' };
91100
}
92101
const status = extractStatus(err);
93102
if (status !== undefined) {
94-
const byStatus = classifyByStatus(status, err);
103+
const byStatus = classifyByStatus(status, err, wire);
95104
if (byStatus) return byStatus;
96105
}
97106
const byNet = classifyByNetwork(err);
@@ -289,7 +298,7 @@ export async function completeWithRetry(
289298
maxRetries,
290299
baseDelayMs,
291300
classify: (err) => {
292-
const decision = classifyError(err);
301+
const decision = classifyError(err, retryOpts.wire);
293302
const retryCount = Math.max(0, attemptForLog - 1);
294303
const normalized = normalizeProviderError(err, provider, retryCount);
295304
if (shouldStop(decision, attemptForLog, maxRetries)) {

0 commit comments

Comments
 (0)