Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,7 @@ export async function generateViaAgent(
ms: Date.now() - sendStart,
errorClass: err instanceof Error ? err.constructor.name : typeof err,
});
throw remapProviderError(err, input.model.provider);
throw remapProviderError(err, input.model.provider, input.wire);
}

const finalAssistant = findFinalAssistantMessage(agent.state.messages);
Expand Down Expand Up @@ -913,6 +913,7 @@ export async function generateViaAgent(
throw remapProviderError(
new CodesignError(message, ERROR_CODES.PROVIDER_ERROR),
input.model.provider,
input.wire,
);
}
log.info('[generate] step=send_request.ok', { ...ctx, ms: Date.now() - sendStart });
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,39 @@ describe('remapProviderError', () => {
expect(out).toBe(err);
});

it('tags 5xx "not implemented" bodies as PROVIDER_GATEWAY_INCOMPATIBLE on anthropic wire', () => {
const err = httpError(500, 'not implemented');
const out = remapProviderError(err, 'anthropic', 'anthropic');
expect(out).toBeInstanceOf(CodesignError);
expect((out as CodesignError).code).toBe('PROVIDER_GATEWAY_INCOMPATIBLE');
expect((out as CodesignError).message).toContain('not implemented');
});

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

it('does NOT remap 5xx "not implemented" to gateway-incompatible on openai-chat wire', () => {
const err = httpError(501, 'not implemented');
const out = remapProviderError(err, 'openai', 'openai-chat');
// Non-anthropic wire: 501 is just a generic upstream error, pass through.
expect(out).toBe(err);
});

it('does NOT remap 501 on openai-responses wire even when body matches gateway pattern', () => {
const err = httpError(501, 'messages api not supported');
const out = remapProviderError(err, 'openai', 'openai-responses');
expect(out).toBe(err);
});

it('does NOT remap when wire is undefined (safer default: pass through)', () => {
const err = httpError(500, 'not implemented');
const out = remapProviderError(err, 'anthropic');
expect(out).toBe(err);
});

it('extracts status code from CodesignError messages that embed it', () => {
const err = new CodesignError(
'HTTP 401 — see https://platform.openai.com/account/api-keys',
Expand Down
25 changes: 23 additions & 2 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
* layer logs them with `reason`.
*/

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

export const PROVIDER_KEY_HELP_URL: Partial<Record<ProviderId, string>> = {
Expand Down Expand Up @@ -98,9 +99,29 @@ export function rewriteUpstreamMessage(
* 4xx errors are rewritten — everything else is rethrown unchanged so the
* retry/network layer keeps its own taxonomy.
*/
export function remapProviderError(err: unknown, provider: string | undefined): unknown {
export function remapProviderError(
err: unknown,
provider: string | undefined,
wire?: WireApi | undefined,
): unknown {
if (!(err instanceof Error)) return err;
if (err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_ABORTED) return err;
// Third-party Anthropic relays often reply to POST /v1/messages with 5xx +
// "not implemented" while their /v1/models endpoint works. Catch that shape
// before any other classification so the UI can suggest switching wire
// instead of the misleading default "check your API key" message. Guard on
// wire === 'anthropic' because the actionable hint ("switch wire to
// openai-chat") only makes sense for Anthropic-compatible endpoints — a 501
// from an OpenAI/Google wire is just a generic upstream error.
if (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里缺少 provider 作用域判断。当前逻辑会把任何 provider 的 501/not implemented 都标记为 PROVIDER_GATEWAY_INCOMPATIBLE,但该错误文案是 Anthropic Messages API 专用,存在误导风险。

建议最小修复:

if (provider === 'anthropic' && looksLikeGatewayMissingMessagesApi(err)) {
  // ...
}

wire === 'anthropic' &&
!(err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE) &&
looksLikeGatewayMissingMessagesApi(err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里仍缺少 provider 作用域。当前会把任何 provider 的 501/not implemented 都映射为 PROVIDER_GATEWAY_INCOMPATIBLE,但该文案是 Anthropic Messages API 专用,存在误导风险。

建议最小修复:

if (
  provider === 'anthropic' &&
  !(err instanceof CodesignError && err.code === ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE) &&
  looksLikeGatewayMissingMessagesApi(err)
) {
  return new CodesignError(err.message, ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE, {
    cause: err,
  });
}

) {
return new CodesignError(err.message, ERROR_CODES.PROVIDER_GATEWAY_INCOMPATIBLE, {
cause: err,
});
}
const status = statusFromError(err);
if (status === undefined || status < 400 || status >= 500) return err;
const { message, rewritten } = rewriteUpstreamMessage(err.message, provider, status);
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ async function runModel(input: ModelRunInput): Promise<GenerateOutput> {
...(input.onRetry !== undefined ? { onRetry: input.onRetry } : {}),
logger: log,
provider: input.model.provider,
...(input.wire !== undefined ? { wire: input.wire } : {}),
},
complete,
);
Expand Down Expand Up @@ -396,7 +397,7 @@ async function runModel(input: ModelRunInput): Promise<GenerateOutput> {
reasoning = undefined;
continue;
}
const remapped = remapProviderError(err, input.model.provider);
const remapped = remapProviderError(err, input.model.provider, input.wire);
log.error(`[${scope}] step=send_request.fail`, {
...ctx,
ms: Date.now() - sendStart,
Expand Down Expand Up @@ -808,6 +809,7 @@ export async function generateTitle(input: GenerateTitleInput): Promise<string>
{
logger: log,
provider: input.model.provider,
...(input.wire !== undefined ? { wire: input.wire } : {}),
},
);
log.info('[title] step=send_request.ok', { ms: Date.now() - started });
Expand All @@ -821,6 +823,6 @@ export async function generateTitle(input: GenerateTitleInput): Promise<string>
ms: Date.now() - started,
errorClass: err instanceof Error ? err.constructor.name : typeof err,
});
throw remapProviderError(err, input.model.provider);
throw remapProviderError(err, input.model.provider, input.wire);
}
}
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@
"PROVIDER_ERROR": "The provider returned an error. Check your API key and try again.",
"PROVIDER_HTTP_4XX": "The provider rejected the request. Verify your API key and billing.",
"PROVIDER_UPSTREAM_ERROR": "The provider returned an unexpected error. Details are in the log.",
"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.",
"PROVIDER_ABORTED": "Generation was cancelled.",
"PROVIDER_RETRY_EXHAUSTED": "The provider failed after several retries. Check your connection and try again.",
"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.",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,7 @@
"PROVIDER_ERROR": "provider 返回错误,请检查 API key 后重试。",
"PROVIDER_HTTP_4XX": "provider 拒绝请求,请检查 API key 和账户余额。",
"PROVIDER_UPSTREAM_ERROR": "provider 返回未知错误,详情见日志。",
"PROVIDER_GATEWAY_INCOMPATIBLE": "网关对 Messages API 返回 “not implemented”。请到设置中把 wire 切换为 openai-chat,或更换支持 Anthropic Messages API 的网关。",
"PROVIDER_ABORTED": "生成已取消。",
"PROVIDER_RETRY_EXHAUSTED": "多次重试后 provider 仍然失败,请检查网络后重试。",
"CLAUDE_CODE_OAUTH_ONLY": "你的 Claude Code 使用的是 Anthropic 订阅(Pro/Max),第三方应用无法复用订阅额度——请到 console.anthropic.com 生成 API key 后填入。",
Expand Down
38 changes: 38 additions & 0 deletions packages/providers/src/gateway-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { looksLikeGatewayMissingMessagesApi } from './gateway-compat';

describe('looksLikeGatewayMissingMessagesApi', () => {
it('matches plain "not implemented"', () => {
expect(looksLikeGatewayMissingMessagesApi(new Error('500 not implemented'))).toBe(true);
});

it('matches "Not Implemented" with different case and spacing', () => {
expect(looksLikeGatewayMissingMessagesApi(new Error('Not Implemented'))).toBe(true);
});

it('matches "Messages API not supported"', () => {
expect(
looksLikeGatewayMissingMessagesApi(new Error('Messages API not supported on this relay')),
).toBe(true);
});

it('matches "unsupported Messages API" phrasing', () => {
expect(looksLikeGatewayMissingMessagesApi(new Error('unsupported messages api endpoint'))).toBe(
true,
);
});

it('matches bare 501 status code in text', () => {
expect(looksLikeGatewayMissingMessagesApi(new Error('HTTP 501 from gateway'))).toBe(true);
});

it('ignores ordinary 500 messages that do not mention not-implemented', () => {
expect(looksLikeGatewayMissingMessagesApi(new Error('500 internal server error'))).toBe(false);
});

it('handles non-Error inputs safely', () => {
expect(looksLikeGatewayMissingMessagesApi(undefined)).toBe(false);
expect(looksLikeGatewayMissingMessagesApi(null)).toBe(false);
expect(looksLikeGatewayMissingMessagesApi('not implemented')).toBe(true);
});
});
27 changes: 27 additions & 0 deletions packages/providers/src/gateway-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Gateway compatibility detection.
*
* Third-party Anthropic-compatible relays (sub2api, claude2api, anyrouter…)
* frequently implement GET /v1/models (which is what our connection test
* hits) but stub out POST /v1/messages with "not implemented" / 501. That
* combination passes the onboarding check but explodes on the first real
* generation. Treating it as a retryable 5xx wastes the user's time with
* exponential backoff and surfaces a misleading "check your API key" blurb.
*
* This helper detects the tell-tale upstream text so both the retry layer
* (to short-circuit) and the core error remapper (to tag it with an
* actionable code) can react correctly.
*/

const NOT_IMPLEMENTED_PATTERNS: readonly RegExp[] = [
/not\s+implemented/i,
/unsupported.*messages?\s*api/i,
/messages?\s*api.*not\s*supported/i,
/\b501\b/,
];

export function looksLikeGatewayMissingMessagesApi(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err ?? '');
if (!msg) return false;
return NOT_IMPLEMENTED_PATTERNS.some((re) => re.test(msg));
}
2 changes: 2 additions & 0 deletions packages/providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,8 @@ export type {
RetryReason,
} from './retry';

export { looksLikeGatewayMissingMessagesApi } from './gateway-compat';

export { injectSkillsIntoMessages, formatSkillsForPrompt, filterActive } from './skill-injector';

// Tier 2 surface (not yet implemented):
Expand Down
14 changes: 14 additions & 0 deletions packages/providers/src/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ describe('classifyError', () => {
it('marks 5xx as retryable', () => {
expect(classifyError(new HttpError('boom', 503))).toMatchObject({ retry: true });
});
it('does not retry 5xx when body says Messages API is not implemented on anthropic wire', () => {
const d = classifyError(new HttpError('500 not implemented', 500), 'anthropic');
expect(d.retry).toBe(false);
expect(d.reason).toMatch(/gateway does not implement Messages API/);
});
it('still retries 5xx "not implemented" on openai-chat wire (not a gateway-compat issue)', () => {
const d = classifyError(new HttpError('500 not implemented', 500), 'openai-chat');
expect(d.retry).toBe(true);
expect(d.reason).toMatch(/server error/);
});
it('still retries 5xx "not implemented" when wire is unknown (safer default)', () => {
const d = classifyError(new HttpError('500 not implemented', 500));
expect(d.retry).toBe(true);
});
it('marks 4xx (non-429) as non-retryable', () => {
expect(classifyError(new HttpError('bad', 400))).toMatchObject({ retry: false });
});
Expand Down
28 changes: 23 additions & 5 deletions packages/providers/src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@
* - any AbortSignal abort short-circuits immediately, no retry
*/

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

export interface RetryReason {
Expand All @@ -31,6 +38,7 @@ export interface CompleteWithRetryOptions {
onRetry?: (info: RetryReason) => void;
logger?: { warn: (event: string, data?: Record<string, unknown>) => void };
provider?: string;
wire?: WireApi;
}

const DEFAULT_MAX_RETRIES = 3;
Expand All @@ -50,14 +58,24 @@ const RETRYABLE_NET_CODES = new Set([
'ECONNREFUSED',
]);

function classifyByStatus(status: number, err: unknown): RetryDecision | undefined {
function classifyByStatus(status: number, err: unknown, wire?: WireApi): RetryDecision | undefined {
if (status === 429) {
const retryAfterMs = extractRetryAfterMs(err);
const decision: RetryDecision = { retry: true, reason: 'rate-limited (429)' };
if (retryAfterMs !== undefined) decision.retryAfterMs = retryAfterMs;
return decision;
}
if (status >= 500 && status <= 599) {
// Third-party Anthropic relays (sub2api, claude2api, anyrouter…) often
// return 5xx + "not implemented" for POST /v1/messages even though their
// /v1/models endpoint works. Retrying wastes 3 rounds of exponential
// backoff on an endpoint that will never respond; short-circuit so the
// user sees the actionable error immediately. Only applies to
// anthropic-wire endpoints — OpenAI/Google wires can emit the same text
// for unrelated reasons and should retry normally.
if (wire === 'anthropic' && looksLikeGatewayMissingMessagesApi(err)) {
return { retry: false, reason: 'gateway does not implement Messages API' };
}
return { retry: true, reason: `server error (${status})` };
}
if (status >= 400 && status <= 499) {
Expand All @@ -76,13 +94,13 @@ function classifyByNetwork(err: unknown): RetryDecision | undefined {
return undefined;
}

export function classifyError(err: unknown): RetryDecision {
export function classifyError(err: unknown, wire?: WireApi): RetryDecision {
if (err instanceof Error && (err.name === 'AbortError' || err.message === 'aborted')) {
return { retry: false, reason: 'aborted' };
}
const status = extractStatus(err);
if (status !== undefined) {
const byStatus = classifyByStatus(status, err);
const byStatus = classifyByStatus(status, err, wire);
if (byStatus) return byStatus;
}
const byNet = classifyByNetwork(err);
Expand Down Expand Up @@ -280,7 +298,7 @@ export async function completeWithRetry(
maxRetries,
baseDelayMs,
classify: (err) => {
const decision = classifyError(err);
const decision = classifyError(err, retryOpts.wire);
const retryCount = Math.max(0, attemptForLog - 1);
const normalized = normalizeProviderError(err, provider, retryCount);
if (shouldStop(decision, attemptForLog, maxRetries)) {
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const ERROR_CODES = {
PROVIDER_ERROR: 'PROVIDER_ERROR',
PROVIDER_HTTP_4XX: 'PROVIDER_HTTP_4XX',
PROVIDER_UPSTREAM_ERROR: 'PROVIDER_UPSTREAM_ERROR',
PROVIDER_GATEWAY_INCOMPATIBLE: 'PROVIDER_GATEWAY_INCOMPATIBLE',
PROVIDER_ABORTED: 'PROVIDER_ABORTED',
PROVIDER_RETRY_EXHAUSTED: 'PROVIDER_RETRY_EXHAUSTED',
CLAUDE_CODE_OAUTH_ONLY: 'CLAUDE_CODE_OAUTH_ONLY',
Expand Down Expand Up @@ -163,6 +164,13 @@ export const ERROR_CODE_DESCRIPTIONS: Record<CodesignErrorCode, ErrorCodeDescrip
userFacingKey: 'err.PROVIDER_UPSTREAM_ERROR',
category: 'provider',
},
PROVIDER_GATEWAY_INCOMPATIBLE: {
userFacing:
"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.',
userFacingKey: 'err.PROVIDER_GATEWAY_INCOMPATIBLE',
category: 'provider',
},
PROVIDER_ABORTED: {
userFacing: 'Generation was cancelled.',
userFacingKey: 'err.PROVIDER_ABORTED',
Expand Down
Loading