Skip to content

Commit 5664e59

Browse files
authored
fix(providers): detect gateway missing Messages API and surface actionable error (#158) (#161)
## Summary 第三方中转(sub2api / claude2api / anyrouter / cpa 反代等)经常实现了 \`/v1/models\` 但没实现完整的 Anthropic Messages API。结果:测试连接通过,真实生成 500 not implemented。当前文案是通用 "The provider returned an error. Check your API key" —— 完全误导用户。 ## What changed - **新增** \`packages/providers/src/gateway-compat.ts\`:\`looksLikeGatewayMissingMessagesApi\` 用 4 个 pattern 识别 not-implemented 文案 - **\`packages/providers/src/retry.ts\`**:5xx 命中 not-implemented pattern 时 \`retry: false\`,避免用户白等 3 次指数退避 - **\`packages/core/src/errors.ts\`**:\`remapProviderError\` 命中时归类成新的 \`PROVIDER_GATEWAY_INCOMPATIBLE\` - **\`packages/shared/src/error-codes.ts\`** + i18n (en/zh-CN):新错误码 + 用户文案 "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." ## Test plan - [x] \`packages/providers/src/gateway-compat.test.ts\` (7 cases) covers 4 not-implemented patterns + non-matching 5xx - [x] \`packages/providers/src/retry.test.ts\` regression: 500 + "not implemented" → \`retry: false\` - [x] \`packages/core/src/errors.test.ts\` (2 new cases) covers PROVIDER_GATEWAY_INCOMPATIBLE classification - [x] \`pnpm test\` providers (139) + core (218) all green - [x] \`pnpm typecheck\` 10/10 - [x] \`pnpm lint\` clean Closes #158 --------- Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent e38ea2d commit 5664e59

12 files changed

Lines changed: 176 additions & 10 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,39 @@ describe('remapProviderError', () => {
8383
expect(out).toBe(err);
8484
});
8585

86+
it('tags 5xx "not implemented" bodies as PROVIDER_GATEWAY_INCOMPATIBLE on anthropic wire', () => {
87+
const err = httpError(500, 'not implemented');
88+
const out = remapProviderError(err, 'anthropic', '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 on anthropic wire', () => {
95+
const out = remapProviderError(new Error('HTTP 501 from gateway'), 'anthropic', 'anthropic');
96+
expect(out).toBeInstanceOf(CodesignError);
97+
expect((out as CodesignError).code).toBe('PROVIDER_GATEWAY_INCOMPATIBLE');
98+
});
99+
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+
86119
it('extracts status code from CodesignError messages that embed it', () => {
87120
const err = new CodesignError(
88121
'HTTP 401 — see https://platform.openai.com/account/api-keys',

packages/core/src/errors.ts

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

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

2122
export const PROVIDER_KEY_HELP_URL: Partial<Record<ProviderId, string>> = {
@@ -98,9 +99,29 @@ export function rewriteUpstreamMessage(
9899
* 4xx errors are rewritten — everything else is rethrown unchanged so the
99100
* retry/network layer keeps its own taxonomy.
100101
*/
101-
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 {
102107
if (!(err instanceof Error)) return err;
103108
if (err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_ABORTED) return err;
109+
// Third-party Anthropic relays often reply to POST /v1/messages with 5xx +
110+
// "not implemented" while their /v1/models endpoint works. Catch that shape
111+
// before any other classification so the UI can suggest switching wire
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.
116+
if (
117+
wire === 'anthropic' &&
118+
!(err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE) &&
119+
looksLikeGatewayMissingMessagesApi(err)
120+
) {
121+
return new CodesignError(err.message, ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE, {
122+
cause: err,
123+
});
124+
}
104125
const status = statusFromError(err);
105126
if (status === undefined || status < 400 || status >= 500) return err;
106127
const { message, rewritten } = rewriteUpstreamMessage(err.message, provider, status);

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/i18n/src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,6 +1032,7 @@
10321032
"PROVIDER_ERROR": "The provider returned an error. Check your API key and try again.",
10331033
"PROVIDER_HTTP_4XX": "The provider rejected the request. Verify your API key and billing.",
10341034
"PROVIDER_UPSTREAM_ERROR": "The provider returned an unexpected error. Details are in the log.",
1035+
"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.",
10351036
"PROVIDER_ABORTED": "Generation was cancelled.",
10361037
"PROVIDER_RETRY_EXHAUSTED": "The provider failed after several retries. Check your connection and try again.",
10371038
"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
@@ -1028,6 +1028,7 @@
10281028
"PROVIDER_ERROR": "provider 返回错误,请检查 API key 后重试。",
10291029
"PROVIDER_HTTP_4XX": "provider 拒绝请求,请检查 API key 和账户余额。",
10301030
"PROVIDER_UPSTREAM_ERROR": "provider 返回未知错误,详情见日志。",
1031+
"PROVIDER_GATEWAY_INCOMPATIBLE": "网关对 Messages API 返回 “not implemented”。请到设置中把 wire 切换为 openai-chat,或更换支持 Anthropic Messages API 的网关。",
10311032
"PROVIDER_ABORTED": "生成已取消。",
10321033
"PROVIDER_RETRY_EXHAUSTED": "多次重试后 provider 仍然失败,请检查网络后重试。",
10331034
"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
@@ -445,6 +445,8 @@ export type {
445445
RetryReason,
446446
} from './retry';
447447

448+
export { looksLikeGatewayMissingMessagesApi } from './gateway-compat';
449+
448450
export { injectSkillsIntoMessages, formatSkillsForPrompt, filterActive } from './skill-injector';
449451

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

packages/providers/src/retry.test.ts

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

0 commit comments

Comments
 (0)