Skip to content

Commit b45e91b

Browse files
committed
fix(providers): detect gateway missing Messages API and surface actionable error (#158)
Third-party Anthropic relays (sub2api, claude2api, anyrouter…) often implement GET /v1/models but stub POST /v1/messages with "not implemented" / 501. The connection test passes but real generation fails. Previously this was treated as a retryable 5xx — users waited three rounds of exponential backoff only to see a generic "check your API key" message that points them in the wrong direction. - Add looksLikeGatewayMissingMessagesApi in @open-codesign/providers - Short-circuit retry.ts on 5xx + not-implemented bodies - Tag the error with new PROVIDER_GATEWAY_INCOMPATIBLE code in core's remapProviderError so the UI surfaces an actionable message (switch wire to openai-chat, or use a gateway that supports the Messages API) - Locale entries for en and zh-CN Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 022e1b6 commit b45e91b

10 files changed

Lines changed: 118 additions & 0 deletions

File tree

packages/core/src/errors.test.ts

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

86+
it('tags 5xx "not implemented" bodies as PROVIDER_GATEWAY_INCOMPATIBLE', () => {
87+
const err = httpError(500, 'not implemented');
88+
const out = remapProviderError(err, 'anthropic');
89+
expect(out).toBeInstanceOf(CodesignError);
90+
expect((out as CodesignError).code).toBe('PROVIDER_GATEWAY_INCOMPATIBLE');
91+
expect((out as CodesignError).message).toContain('not implemented');
92+
});
93+
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');
96+
expect(out).toBeInstanceOf(CodesignError);
97+
expect((out as CodesignError).code).toBe('PROVIDER_GATEWAY_INCOMPATIBLE');
98+
});
99+
86100
it('extracts status code from CodesignError messages that embed it', () => {
87101
const err = new CodesignError(
88102
'HTTP 401 — see https://platform.openai.com/account/api-keys',

packages/core/src/errors.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* layer logs them with `reason`.
1616
*/
1717

18+
import { looksLikeGatewayMissingMessagesApi } from '@open-codesign/providers';
1819
import type { ProviderId } from '@open-codesign/shared';
1920
import { CodesignError, ERROR_CODES } from '@open-codesign/shared';
2021

@@ -101,6 +102,18 @@ export function rewriteUpstreamMessage(
101102
export function remapProviderError(err: unknown, provider: string | undefined): unknown {
102103
if (!(err instanceof Error)) return err;
103104
if (err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_ABORTED) return err;
105+
// Third-party Anthropic relays often reply to POST /v1/messages with 5xx +
106+
// "not implemented" while their /v1/models endpoint works. Catch that shape
107+
// before any other classification so the UI can suggest switching wire
108+
// instead of the misleading default "check your API key" message.
109+
if (
110+
!(err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE) &&
111+
looksLikeGatewayMissingMessagesApi(err)
112+
) {
113+
return new CodesignError(err.message, ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE, {
114+
cause: err,
115+
});
116+
}
104117
const status = statusFromError(err);
105118
if (status === undefined || status < 400 || status >= 500) return err;
106119
const { message, rewritten } = rewriteUpstreamMessage(err.message, provider, status);

packages/i18n/src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,7 @@
10301030
"PROVIDER_ERROR": "The provider returned an error. Check your API key and try again.",
10311031
"PROVIDER_HTTP_4XX": "The provider rejected the request. Verify your API key and billing.",
10321032
"PROVIDER_UPSTREAM_ERROR": "The provider returned an unexpected error. Details are in the log.",
1033+
"PROVIDER_GATEWAY_INCOMPATIBLE": "Your gateway returned 'not implemented' for the Messages API. Try switching wire to openai-chat in Settings, or use a gateway that supports the Anthropic Messages API.",
10331034
"PROVIDER_ABORTED": "Generation was cancelled.",
10341035
"PROVIDER_RETRY_EXHAUSTED": "The provider failed after several retries. Check your connection and try again.",
10351036
"CLAUDE_CODE_OAUTH_ONLY": "Your Claude Code login uses an Anthropic subscription (Pro/Max). Third-party apps cannot reuse the subscription quota — generate an API key at console.anthropic.com and use it here.",

packages/i18n/src/locales/zh-CN.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,7 @@
10261026
"PROVIDER_ERROR": "provider 返回错误,请检查 API key 后重试。",
10271027
"PROVIDER_HTTP_4XX": "provider 拒绝请求,请检查 API key 和账户余额。",
10281028
"PROVIDER_UPSTREAM_ERROR": "provider 返回未知错误,详情见日志。",
1029+
"PROVIDER_GATEWAY_INCOMPATIBLE": "网关对 Messages API 返回 “not implemented”。请到设置中把 wire 切换为 openai-chat,或更换支持 Anthropic Messages API 的网关。",
10291030
"PROVIDER_ABORTED": "生成已取消。",
10301031
"PROVIDER_RETRY_EXHAUSTED": "多次重试后 provider 仍然失败,请检查网络后重试。",
10311032
"CLAUDE_CODE_OAUTH_ONLY": "你的 Claude Code 使用的是 Anthropic 订阅(Pro/Max),第三方应用无法复用订阅额度——请到 console.anthropic.com 生成 API key 后填入。",
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { looksLikeGatewayMissingMessagesApi } from './gateway-compat';
3+
4+
describe('looksLikeGatewayMissingMessagesApi', () => {
5+
it('matches plain "not implemented"', () => {
6+
expect(looksLikeGatewayMissingMessagesApi(new Error('500 not implemented'))).toBe(true);
7+
});
8+
9+
it('matches "Not Implemented" with different case and spacing', () => {
10+
expect(looksLikeGatewayMissingMessagesApi(new Error('Not Implemented'))).toBe(true);
11+
});
12+
13+
it('matches "Messages API not supported"', () => {
14+
expect(
15+
looksLikeGatewayMissingMessagesApi(new Error('Messages API not supported on this relay')),
16+
).toBe(true);
17+
});
18+
19+
it('matches "unsupported Messages API" phrasing', () => {
20+
expect(looksLikeGatewayMissingMessagesApi(new Error('unsupported messages api endpoint'))).toBe(
21+
true,
22+
);
23+
});
24+
25+
it('matches bare 501 status code in text', () => {
26+
expect(looksLikeGatewayMissingMessagesApi(new Error('HTTP 501 from gateway'))).toBe(true);
27+
});
28+
29+
it('ignores ordinary 500 messages that do not mention not-implemented', () => {
30+
expect(looksLikeGatewayMissingMessagesApi(new Error('500 internal server error'))).toBe(false);
31+
});
32+
33+
it('handles non-Error inputs safely', () => {
34+
expect(looksLikeGatewayMissingMessagesApi(undefined)).toBe(false);
35+
expect(looksLikeGatewayMissingMessagesApi(null)).toBe(false);
36+
expect(looksLikeGatewayMissingMessagesApi('not implemented')).toBe(true);
37+
});
38+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Gateway compatibility detection.
3+
*
4+
* Third-party Anthropic-compatible relays (sub2api, claude2api, anyrouter…)
5+
* frequently implement GET /v1/models (which is what our connection test
6+
* hits) but stub out POST /v1/messages with "not implemented" / 501. That
7+
* combination passes the onboarding check but explodes on the first real
8+
* generation. Treating it as a retryable 5xx wastes the user's time with
9+
* exponential backoff and surfaces a misleading "check your API key" blurb.
10+
*
11+
* This helper detects the tell-tale upstream text so both the retry layer
12+
* (to short-circuit) and the core error remapper (to tag it with an
13+
* actionable code) can react correctly.
14+
*/
15+
16+
const NOT_IMPLEMENTED_PATTERNS: readonly RegExp[] = [
17+
/not\s+implemented/i,
18+
/unsupported.*messages?\s*api/i,
19+
/messages?\s*api.*not\s*supported/i,
20+
/\b501\b/,
21+
];
22+
23+
export function looksLikeGatewayMissingMessagesApi(err: unknown): boolean {
24+
const msg = err instanceof Error ? err.message : String(err ?? '');
25+
if (!msg) return false;
26+
return NOT_IMPLEMENTED_PATTERNS.some((re) => re.test(msg));
27+
}

packages/providers/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,8 @@ export type {
419419
RetryReason,
420420
} from './retry';
421421

422+
export { looksLikeGatewayMissingMessagesApi } from './gateway-compat';
423+
422424
export { injectSkillsIntoMessages, formatSkillsForPrompt, filterActive } from './skill-injector';
423425

424426
// Tier 2 surface (not yet implemented):

packages/providers/src/retry.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ 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));
29+
expect(d.retry).toBe(false);
30+
expect(d.reason).toMatch(/gateway does not implement Messages API/);
31+
});
2732
it('marks 4xx (non-429) as non-retryable', () => {
2833
expect(classifyError(new HttpError('bad', 400))).toMatchObject({ retry: false });
2934
});

packages/providers/src/retry.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import { type ChatMessage, CodesignError, ERROR_CODES, type ModelRef } from '@open-codesign/shared';
1717
import { normalizeProviderError } from './errors';
18+
import { looksLikeGatewayMissingMessagesApi } from './gateway-compat';
1819
import { type GenerateOptions, type GenerateResult, complete } from './index';
1920

2021
export interface RetryReason {
@@ -58,6 +59,14 @@ function classifyByStatus(status: number, err: unknown): RetryDecision | undefin
5859
return decision;
5960
}
6061
if (status >= 500 && status <= 599) {
62+
// Third-party Anthropic relays (sub2api, claude2api, anyrouter…) often
63+
// return 5xx + "not implemented" for POST /v1/messages even though their
64+
// /v1/models endpoint works. Retrying wastes 3 rounds of exponential
65+
// backoff on an endpoint that will never respond; short-circuit so the
66+
// user sees the actionable error immediately.
67+
if (looksLikeGatewayMissingMessagesApi(err)) {
68+
return { retry: false, reason: 'gateway does not implement Messages API' };
69+
}
6170
return { retry: true, reason: `server error (${status})` };
6271
}
6372
if (status >= 400 && status <= 499) {

packages/shared/src/error-codes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const ERROR_CODES = {
2626
PROVIDER_ERROR: 'PROVIDER_ERROR',
2727
PROVIDER_HTTP_4XX: 'PROVIDER_HTTP_4XX',
2828
PROVIDER_UPSTREAM_ERROR: 'PROVIDER_UPSTREAM_ERROR',
29+
PROVIDER_GATEWAY_INCOMPATIBLE: 'PROVIDER_GATEWAY_INCOMPATIBLE',
2930
PROVIDER_ABORTED: 'PROVIDER_ABORTED',
3031
PROVIDER_RETRY_EXHAUSTED: 'PROVIDER_RETRY_EXHAUSTED',
3132
CLAUDE_CODE_OAUTH_ONLY: 'CLAUDE_CODE_OAUTH_ONLY',
@@ -163,6 +164,13 @@ export const ERROR_CODE_DESCRIPTIONS: Record<CodesignErrorCode, ErrorCodeDescrip
163164
userFacingKey: 'err.PROVIDER_UPSTREAM_ERROR',
164165
category: 'provider',
165166
},
167+
PROVIDER_GATEWAY_INCOMPATIBLE: {
168+
userFacing:
169+
"Your gateway returned 'not implemented' for the Messages API. " +
170+
'Try switching wire to openai-chat in Settings, or use a gateway that supports the Anthropic Messages API.',
171+
userFacingKey: 'err.PROVIDER_GATEWAY_INCOMPATIBLE',
172+
category: 'provider',
173+
},
166174
PROVIDER_ABORTED: {
167175
userFacing: 'Generation was cancelled.',
168176
userFacingKey: 'err.PROVIDER_ABORTED',

0 commit comments

Comments
 (0)