From 34d509cc34ee17fa826ccf09ae99e85cd8d7b825 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 11 Aug 2026 18:15:34 +0800 Subject: [PATCH 01/18] fix(runtime): distinguish account limits from auth errors Keep ambiguous 403 responses out of authentication classification, preserve the bounded provider explanation added by #2675, and use structured provider identifiers for stable account-state meanings. Generated-by: Maka --- .../provider-failure-presentation.test.ts | 33 ++++++ .../src/renderer/locales/conversation-copy.ts | 6 +- .../renderer/session-error-presentation.ts | 4 + .../renderer/session-status-presentation.ts | 21 +++- .../cli/src/__tests__/pi-transcript.test.ts | 25 +++++ .../src/__tests__/model-adapter.test.ts | 27 +++++ .../provider-error-classification.test.ts | 103 ++++++++++++++++++ packages/runtime/src/model-adapter.ts | 8 ++ packages/runtime/src/model-protocol.ts | 2 + .../src/provider-error-classification.ts | 97 ++++++++++++++--- 10 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts new file mode 100644 index 0000000000..d7f692c458 --- /dev/null +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; + +import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js'; +import { + deriveFailedTurnRecovery, + describeTurnErrorClass, +} from '../../renderer/session-status-presentation.js'; + +describe('provider failure presentation', () => { + test('keeps provider account and access failures distinct in both locales', () => { + assert.equal(describeSessionErrorReason('usage_limit'), '模型使用额度已用完'); + assert.equal(describeSessionErrorReason('provider_permission'), '模型服务拒绝访问'); + assert.equal(describeSessionErrorReason('usage_limit', 'en'), 'Model usage limit reached'); + assert.equal(describeSessionErrorReason('provider_permission', 'en'), 'Provider access denied'); + }); + + test('does not present a bare 403 as an authentication failure', () => { + assert.equal(describeTurnErrorClass('403'), '未知错误'); + assert.deepEqual( + deriveFailedTurnRecovery({ + errorClass: 'usage_limit', + partialOutputRetained: false, + toolActivityCount: 0, + erroredToolCount: 0, + }), + { + action: 'check_account', + label: '检查模型服务的额度、套餐或恢复时间', + }, + ); + }); +}); diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index 9fc6c9f9a8..c40b96449d 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -302,7 +302,9 @@ export interface DesktopConversationCopy { auth: string; providerBilling: string; providerCapacity: string; + providerPermission: string; rateLimit: string; + usageLimit: string; network: string; provider: string; stepCap: string; @@ -310,7 +312,7 @@ export interface DesktopConversationCopy { permission: string; restarted: string; sandboxBoundaryClosed: string; - recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'sandboxBoundaryClosed', string>; + recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'account' | 'partial' | 'toolRecord' | 'retry' | 'capacity' | 'contextOverflow' | 'sandboxBoundaryClosed', string>; }; } @@ -581,6 +583,7 @@ const COPY = { testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, + turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerPermission: '模型服务拒绝访问', rateLimit: '触发模型速率限制', usageLimit: '模型使用额度已用完', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', account: '检查模型服务的额度、套餐或恢复时间', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, }, en: { actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, @@ -784,6 +787,7 @@ const COPY = { testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' }, }, turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, + turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerPermission: 'Provider access denied', rateLimit: 'Model rate limit reached', usageLimit: 'Model usage limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', account: 'Check the provider allowance, plan, or reset time', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/session-error-presentation.ts b/apps/desktop/src/renderer/session-error-presentation.ts index 3dde9920c1..ecc2500f21 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -38,10 +38,14 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U return copy.providerBilling; case 'provider_capacity': return copy.providerCapacity; + case 'provider_permission': + return copy.providerPermission; case 'provider_unavailable': return copy.provider; case 'rate_limit': return copy.rateLimit; + case 'usage_limit': + return copy.usageLimit; case 'network': return copy.network; default: diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 74deaa7cb8..08dbdf4e03 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -108,7 +108,9 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U // (#1612), and it must never fall through to the "permission"/"tool" catch-alls. if (lower === SANDBOX_BOUNDARY_RESTART_CLOSURE_CLASS) return copy.sandboxBoundaryClosed; if (lower === 'timeout' || lower.includes('timeout')) return copy.timeout; - if (lower === 'auth' || lower.includes('auth') || lower === '401' || lower === '403') return copy.auth; + if (lower === 'auth' || lower.includes('auth') || lower === '401') return copy.auth; + if (lower === 'provider_permission') return copy.providerPermission; + if (lower === 'usage_limit') return copy.usageLimit; if (lower === 'rate_limit' || lower.includes('rate')) return copy.rateLimit; if (lower === 'network' || lower.includes('network') || lower.includes('fetch') || lower.includes('econn')) { return copy.network; @@ -126,7 +128,12 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U return copy.unknown; } -export type FailedTurnRecoveryAction = 'retry' | 'continue' | 'inspect_tool' | 'check_connection'; +export type FailedTurnRecoveryAction = + | 'retry' + | 'continue' + | 'inspect_tool' + | 'check_connection' + | 'check_account'; export interface FailedTurnRecoveryPresentation { action: FailedTurnRecoveryAction; @@ -165,9 +172,17 @@ export function deriveFailedTurnRecovery(input: FailedTurnRecoveryInput, locale: if (input.erroredToolCount > 0 || lower === 'tool_failed' || lower.includes('tool')) { return { action: 'inspect_tool', label: copy.toolError }; } - if (lower === 'provider_billing' || lower === 'auth' || lower.includes('auth') || lower === '401' || lower === '403') { + if ( + lower === 'provider_permission' || + lower === 'auth' || + lower.includes('auth') || + lower === '401' + ) { return { action: 'check_connection', label: copy.connection }; } + if (lower === 'provider_billing' || lower === 'usage_limit') { + return { action: 'check_account', label: copy.account }; + } if (input.partialOutputRetained) { return { action: 'continue', label: copy.partial }; } diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 72003776aa..6c0e2b755e 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -225,6 +225,31 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('renders a Runtime Host provider-limit explanation as the turn error', () => { + const state = createMakaPiTranscriptState(); + const message = + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle."; + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + code: 'permission_error', + message, + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: message, + }); + assert.match( + renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'), + /usage limit/, + ); + }); test('keeps assistant text after a tool call visible after the tool block', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'inspect the package'); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 99b08f8991..8705adc838 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -670,6 +670,33 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(event.message.includes('sk-live-secret-token-value'), false); }); + test('projects the observed Kimi plan limit as a non-retryable provider explanation', () => { + const observedMessage = + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle. To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/code/#pricing"; + const error = Object.assign(new Error(observedMessage), { + name: 'AI_APICallError', + statusCode: 403, + data: { + type: 'error', + error: { type: 'permission_error', message: observedMessage }, + }, + }); + const adapter = newAdapter(); + + const failure = adapter.normalizeFailure(error); + assert.deepEqual(failure, { + type: 'model_failure', + kind: 'unknown', + retryable: false, + code: 'permission_error', + message: `${observedMessage} (code=permission_error, status=403)`, + }); + const event = adapter.makeErrorEvent('turn-1', failure); + assert.equal(event.reason, undefined); + assert.equal(event.code, 'permission_error'); + assert.equal(event.message, `${observedMessage} (code=permission_error, status=403)`); + }); + test('normalizes cache and reasoning usage variants in the adapter module', () => { assert.deepEqual( normalizeAiSdkUsage({ diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 38195bb01c..c5a262ae39 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -26,6 +26,7 @@ import { z } from 'zod/v4'; import { classifyError, providerFailureDiagnostic, + errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, } from '../provider-error-classification.js'; @@ -491,6 +492,108 @@ describe('Provider error classification', () => { 'AI_RetryError', ); }); + + test('separates structured account limits, permission, and transient throttling', () => { + const providerError = ( + statusCode: number, + message: string, + structured: Record = {}, + ) => + Object.assign(new Error(message), { + name: 'AI_APICallError', + statusCode, + data: { error: { message, ...structured } }, + }); + + const insufficientQuota = providerError(429, 'You exceeded your current quota', { + type: 'insufficient_quota', + code: 'insufficient_quota', + }); + assert.equal(classifyError(insufficientQuota), 'ProviderBilling'); + assert.deepEqual(providerRetryMetadata(insufficientQuota), { retryable: false }); + + const planUsageLimit = providerError(429, 'Your subscription usage limit has been reached', { + type: 'usage_limit_reached', + }); + assert.equal(classifyError(planUsageLimit), 'UsageLimit'); + assert.deepEqual(providerRetryMetadata(planUsageLimit), { retryable: false }); + + const permission = providerError(403, 'This key cannot access the requested model', { + type: 'permission_denied', + }); + assert.equal(classifyError(permission), 'ProviderPermission'); + assert.deepEqual(providerRetryMetadata(permission), { retryable: false }); + + const throttle = providerError(429, 'Too many requests', { + code: 'rate_limit_exceeded', + }); + assert.equal(classifyError(throttle), 'RateLimit'); + assert.deepEqual(providerRetryMetadata(throttle), { retryable: true }); + assert.deepEqual(providerRetryMetadata(Object.assign(throttle, { isRetryable: false })), { + retryable: false, + }); + + assert.equal(classifyError(providerError(401, 'Invalid API key')), 'Auth'); + assert.equal(classifyError(providerError(403, 'Request forbidden')), 'AI_APICallError'); + assert.equal(classifyError(providerError(400, 'Quota exceeded')), 'AI_APICallError'); + }); + + test('keeps the observed Kimi plan-limit explanation without guessing its account state', async () => { + const handler = createJsonErrorResponseHandler({ + errorSchema: z.object({ + type: z.literal('error'), + error: z.object({ + type: z.string(), + message: z.string(), + }), + }), + errorToMessage: (data) => data.error.message, + }); + const observedMessage = + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle. To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/code/#pricing"; + const planCycleLimit = ( + await handler({ + response: new Response( + JSON.stringify({ + error: { type: 'permission_error', message: observedMessage }, + type: 'error', + }), + { + status: 403, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }, + ), + url: 'https://api.example.test/coding/v1/messages', + requestBodyValues: {}, + }) + ).value; + + assert.equal(classifyError(planCycleLimit), 'AI_APICallError'); + assert.deepEqual(providerRetryMetadata(planCycleLimit), { retryable: false }); + assert.deepEqual(providerFailureSummary(planCycleLimit), { + message: `${observedMessage} (code=permission_error, status=403)`, + code: 'permission_error', + }); + }); + + test('maps provider classes to stable user-safe presentations', () => { + assert.deepEqual(errorPresentationFromClass('ProviderBilling'), { + reason: 'provider_billing', + message: 'Provider billing required', + }); + assert.deepEqual(errorPresentationFromClass('ProviderPermission'), { + reason: 'provider_permission', + message: 'Provider access denied', + }); + assert.deepEqual(errorPresentationFromClass('RateLimit'), { + reason: 'rate_limit', + message: 'Rate limit exceeded', + }); + assert.deepEqual(errorPresentationFromClass('UsageLimit'), { + reason: 'usage_limit', + message: 'Usage limit reached', + }); + }); }); test('auth classification matches authentication without matching authority', () => { diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index a21f89f3e1..34eeeb3663 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -929,12 +929,16 @@ function modelFailureKind(errorClass: string): ModelFailureKind { return 'provider_billing'; case 'ProviderCapacity': return 'provider_capacity'; + case 'ProviderPermission': + return 'provider_permission'; case 'ProviderUnavailable': return 'provider_unavailable'; case 'RateLimit': return 'rate_limit'; case 'Timeout': return 'timeout'; + case 'UsageLimit': + return 'usage_limit'; default: return 'unknown'; } @@ -954,12 +958,16 @@ function errorClassFromFailureKind(kind: ModelFailureKind): string { return 'ProviderBilling'; case 'provider_capacity': return 'ProviderCapacity'; + case 'provider_permission': + return 'ProviderPermission'; case 'provider_unavailable': return 'ProviderUnavailable'; case 'rate_limit': return 'RateLimit'; case 'timeout': return 'Timeout'; + case 'usage_limit': + return 'UsageLimit'; case 'unknown': return 'Other'; } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index fe1a1db769..03373c6c07 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -335,9 +335,11 @@ export type ModelFailureKind = | 'network' | 'provider_capacity' | 'provider_billing' + | 'provider_permission' | 'provider_unavailable' | 'rate_limit' | 'timeout' + | 'usage_limit' | 'unknown'; export interface ModelFailure { diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 30399ed799..ea51501a20 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -32,9 +32,31 @@ const CONTEXT_OVERFLOW_PROVIDER_CODES: ReadonlySet = new Set([ 'request_too_large', // Anthropic byte-size overflow (HTTP 413): error.type ]); +/** Stable account-state meanings exposed by provider-owned structured fields. */ +const PROVIDER_AUTH_CODES: ReadonlySet = new Set([ + 'authentication', + 'authentication_error', + 'invalid_api_key', +]); +const PROVIDER_BILLING_CODES: ReadonlySet = new Set([ + 'insufficient_quota', + 'payment_required', +]); +const PROVIDER_PERMISSION_CODES: ReadonlySet = new Set(['permission_denied']); +const PROVIDER_USAGE_LIMIT_CODES: ReadonlySet = new Set(['usage_limit_reached']); +const PROVIDER_RATE_LIMIT_CODES: ReadonlySet = new Set([ + 'rate_limit_error', + 'rate_limit_exceeded', + 'rate_limited', +]); const PROVIDER_UNAVAILABLE_PROVIDER_CODES: ReadonlySet = new Set([ 'server_error', // OpenAI-compatible stream errors can omit the HTTP status. ]); +const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ + 'overloaded_error', + 'provider_overloaded', + 'provider_unavailable', +]); /** * xAI emits this code for transient model capacity failures, including when @@ -62,7 +84,7 @@ interface ProviderErrorEvidence { statusCode: string; /** Top-level code field as a string ('' when absent). */ code: string; - /** Structured provider identifiers (code/type), lowercased. */ + /** Structured provider identifiers (code/type/error_type/provider_code), lowercased. */ structuredCodes: string[]; } @@ -149,6 +171,16 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { const status = Number(evidence.statusCode || evidence.code); const errorClass = classifyProviderFacts(facts); + // Account state cannot be repaired by immediately repeating the same + // physical request, even when a provider reports it through HTTP 429. + if ( + errorClass === 'Auth' || + errorClass === 'ProviderBilling' || + errorClass === 'ProviderPermission' || + errorClass === 'UsageLimit' + ) { + return { retryable: false }; + } const retryAfterMs = parseRetryAfterMs(facts.responseHeaders ?? {}); if (errorClass === 'ProviderCapacity') { // Capacity is transient even when the provider sends a malformed delay; @@ -176,18 +208,27 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { }; } -/** Collects `code`/`type` strings from a payload and from its `error` wrapper. */ +/** Collects stable identifiers from the provider envelopes Maka receives. */ function collectStructuredCodes(payload: unknown, out: string[]): void { const fromRecord = (record: Record | undefined) => { if (!record) return; - for (const key of ['code', 'type'] as const) { + for (const key of ['code', 'type', 'error_type', 'provider_code'] as const) { const value = safeField(record, key); if (typeof value === 'string' && value) out.push(value.toLowerCase()); } }; const record = providerRecord(payload); fromRecord(record); - fromRecord(record ? providerRecord(safeField(record, 'error')) : undefined); + if (!record) return; + fromRecord(providerRecord(safeField(record, 'metadata'))); + const error = providerRecord(safeField(record, 'error')); + fromRecord(error); + fromRecord(error ? providerRecord(safeField(error, 'metadata')) : undefined); + const response = providerRecord(safeField(record, 'response')); + fromRecord(response); + const responseError = response ? providerRecord(safeField(response, 'error')) : undefined; + fromRecord(responseError); + fromRecord(responseError ? providerRecord(safeField(responseError, 'metadata')) : undefined); } function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined { @@ -204,8 +245,9 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined const rawBody = (target as { responseBody?: unknown }).responseBody; const body = typeof rawBody === 'string' ? rawBody : ''; const structuredCodes: string[] = []; + collectStructuredCodes(target, structuredCodes); collectStructuredCodes((target as { data?: unknown }).data, structuredCodes); - if (structuredCodes.length === 0 && body) { + if (body) { // The failed-response handler keeps the raw body even when the provider // JSON failed the schema (which is exactly when `data` is absent). try { @@ -604,13 +646,17 @@ export function isContextOverflowErrorText(text: string): boolean { /** * Classifies a provider error by DESCENDING evidence strength over the - * normalized evidence (Error, string, or plain stream-error-part object): an - * explicit RetryError abort → known transport codes → the provider's - * structured capacity and overflow codes → numeric HTTP fallbacks → - * vetoable free-text relations → generic 5xx → weak word heuristics. Exact - * provider evidence outranks generic HTTP/text evidence because gateways can - * wrap a provider failure in a misleading status or message; the weak - * heuristics rank last so "generate" can never become a rate limit. + * normalized evidence (Error, string, or plain stream-error-part object): + * abort → structured account state → 402 → 429 → 401 (numeric fields, + * never substrings) → the provider's structured capacity and overflow codes → + * bare 413 (HTTP: request entity too large — itself input-side evidence, + * Cerebras sends it with no body) → numeric HTTP fallbacks → vetoable + * free-text relations → generic 5xx → weak word heuristics. Exact provider + * evidence outranks generic HTTP/text evidence because gateways can wrap a + * provider failure in a misleading status or message; specific overflow + * evidence outranks a generic 5xx because proxies (LiteLLM) wrap provider + * overflows in 503s; the weak heuristics rank last so "generate" can never + * become a rate limit. */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -637,16 +683,31 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (statusCode === '429' || code === '429') return 'RateLimit'; if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') return 'Auth'; + if (structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) return 'Auth'; + if (structuredCodes.some((value) => PROVIDER_BILLING_CODES.has(value))) return 'ProviderBilling'; + if (structuredCodes.some((value) => PROVIDER_PERMISSION_CODES.has(value))) + return 'ProviderPermission'; + if (structuredCodes.some((value) => PROVIDER_USAGE_LIMIT_CODES.has(value))) return 'UsageLimit'; + if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) return 'RateLimit'; + if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) + return 'ProviderUnavailable'; + if (statusCode === '402' || code === '402') return 'ProviderBilling'; + if (statusCode === '429' || code === '429') return 'RateLimit'; + if (statusCode === '401' || code === '401') return 'Auth'; + // A bare 403 is intentionally unknown: providers use it for valid-key + // permission failures, guardrails, subscription limits, and occasionally + // authentication. The provider's bounded diagnostic remains available. + // Structured provider evidence: the parsed error JSON's code/type is the + // only unconditional signal for a context overflow. + if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; if (statusCode === '413' || code === '413') return 'ContextLength'; // Free-text overflow relations on the composite text, veto-first inside. if (isContextOverflowErrorText(text)) return 'ContextLength'; if (structuredCodes.some((c) => PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(c))) return 'ProviderUnavailable'; if (/^5\d\d$/.test(statusCode) || /^5\d\d$/.test(code)) return 'ProviderUnavailable'; - // Weak word heuristics, last: they only catch errors that carried no - // stronger evidence for any other class. `rate` must be word-shaped - // ("generate"/"separate" are not rate limits) while still matching the - // rate_limit/RateLimitError identifier spellings. + // Weak word heuristics remain as compatibility fallbacks after all stronger + // provider facts. They must not override a structured account state. if (/\brate\b|rate[_-]?limit/.test(text)) return 'RateLimit'; if (isAuthenticationErrorText(text)) return 'Auth'; if (text.includes('timeout')) return 'Timeout'; @@ -674,10 +735,14 @@ export function errorPresentationFromClass(errorClass: string): { return { reason: 'provider_billing', message: 'Provider billing required' }; case 'ProviderCapacity': return { reason: 'provider_capacity', message: 'Model service is temporarily at capacity' }; + case 'ProviderPermission': + return { reason: 'provider_permission', message: 'Provider access denied' }; case 'ProviderUnavailable': return { reason: 'provider_unavailable', message: 'Provider returned an error' }; case 'RateLimit': return { reason: 'rate_limit', message: 'Rate limit exceeded' }; + case 'UsageLimit': + return { reason: 'usage_limit', message: 'Usage limit reached' }; case 'Network': return { reason: 'network', message: 'Network error' }; default: From da4142940369562feea3eddf96f8a4601587ec7e Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 13 Aug 2026 12:36:02 +0800 Subject: [PATCH 02/18] fix(desktop): preserve neutral provider failures Generated-by: Maka --- .../provider-failure-presentation.test.ts | 40 +++++++++++++++++++ .../src/renderer/model-connection-errors.ts | 10 ++++- .../renderer/session-status-presentation.ts | 2 +- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index d7f692c458..f690ee00b3 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import type { SessionEvent } from '@maka/core/events'; +import { sessionEventErrorMessage } from '../../renderer/model-connection-errors.js'; import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js'; import { deriveFailedTurnRecovery, @@ -30,4 +32,42 @@ describe('provider failure presentation', () => { }, ); }); + + test('does not present a provider permission code as a local permission wait', () => { + assert.equal(describeTurnErrorClass('permission_required'), '等待权限确认'); + assert.equal(describeTurnErrorClass('permission_error'), '未知错误'); + }); + + test('preserves the bounded provider summary for a neutral Kimi plan-limit event', () => { + const message = + "You've reached your usage limit for this billing cycle. Your quota will be refreshed in the next cycle. " + + 'To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/code/#pricing ' + + '(code=permission_error, status=403)'; + const event: Extract = { + type: 'error', + id: 'event-kimi-plan-limit', + turnId: 'turn-kimi-plan-limit', + ts: 1, + recoverable: false, + code: 'permission_error', + message, + }; + + assert.equal(sessionEventErrorMessage(event), message); + assert.equal(sessionEventErrorMessage(event, 'en'), message); + }); + + test('uses generic copy when an error has neither a known reason nor provider evidence', () => { + const event: Extract = { + type: 'error', + id: 'event-unknown', + turnId: 'turn-unknown', + ts: 1, + recoverable: false, + message: '403 permission denied', + }; + + assert.equal(sessionEventErrorMessage(event), '对话运行失败,请稍后重试。'); + assert.equal(sessionEventErrorMessage(event, 'en'), 'The conversation run failed. Try again later.'); + }); }); diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index cb9a34de3f..4677f6c8fe 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -22,7 +22,6 @@ import type { SessionEvent } from '@maka/core/events'; import type { UiLocale } from '@maka/core/ui-locale'; import { parseNoRealConnectionError } from '@maka/core/connection-error-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy.js'; -import { localizedShellErrorMessage } from './locales/shell-copy.js'; import { describeSessionErrorReason } from './session-error-presentation.js'; const NO_REAL_CONNECTION_CODE = 'NO_REAL_CONNECTION'; @@ -62,8 +61,15 @@ export function sessionEventErrorMessage( } const reasonDescription = describeSessionErrorReason(event.reason, locale); if (reasonDescription) return reasonDescription; + + // Provider errors reach this boundary with a stable code plus the + // allowlisted, redacted, bounded summary produced by ModelAdapter. Keep that + // structured result authoritative instead of reclassifying words or HTTP + // status fragments in the presentation layer. + if (event.code !== undefined && event.message.length > 0) return event.message; + const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback; - return localizedShellErrorMessage(new Error(event.message), fallback, locale); + return fallback; } /** diff --git a/apps/desktop/src/renderer/session-status-presentation.ts b/apps/desktop/src/renderer/session-status-presentation.ts index 08dbdf4e03..b3dead8de5 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -123,7 +123,7 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U return copy.provider; if (lower === 'tool_step_cap_reached') return copy.stepCap; if (lower === 'tool_failed' || lower.includes('tool')) return copy.tool; - if (lower === 'permission_required' || lower.includes('permission')) return copy.permission; + if (lower === 'permission_required') return copy.permission; if (lower === 'app_restarted') return copy.restarted; return copy.unknown; } From 5e6255485d2a7bb1e94818e0d5fa54f47c69f1d6 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 03/18] fix(runtime): bound provider summaries and reuse the shared classifier Mark ModelFailure messages taken from the provider-failure summary as bounded, display-safe provider wording and carry the marker through the durable error content so presentation layers never render unbounded transport text. Connection testing previously kept a parallel status-only classifier that mapped every 401/403 to auth. Route probe failures through the shared provider-failure authority instead: a Kimi 403 carrying a permission envelope stays neutral rather than demanding re-authentication. The durable diagnostic also prefers the status-bearing cause over an SDK wrapper whose transport code would otherwise shadow the real HTTP status. Generated-by: Maka --- packages/core/src/events.ts | 7 +++ packages/core/src/runtime-event.ts | 9 +++- .../runtime/src/__tests__/ai-sdk-flow.test.ts | 9 +++- .../src/__tests__/model-adapter.test.ts | 2 + .../__tests__/provider-conformance.test.ts | 53 +++++++++++++++++++ .../provider-error-classification.test.ts | 12 +++-- packages/runtime/src/ai-sdk-flow.ts | 1 + .../runtime/src/connection-effect-outcome.ts | 20 +++++-- packages/runtime/src/model-adapter.ts | 8 ++- packages/runtime/src/model-protocol.ts | 5 ++ .../src/provider-error-classification.ts | 13 +++-- packages/runtime/src/test-connection.ts | 43 +++++++++++++-- 12 files changed, 163 insertions(+), 19 deletions(-) diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 4a58287e49..59d1a4a31f 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1139,6 +1139,13 @@ export interface ErrorEvent extends BaseEvent { /** Stable machine-readable reason for UI / telemetry routing. */ reason?: string; message: string; + /** + * Marks `message` as the allowlisted, redacted, bounded provider summary + * produced by the Runtime provider-failure boundary. Presentation layers may + * render such a message verbatim; an unmarked message must not be shown raw + * even when `code` is present (Node error codes carry unbounded text). + */ + boundedProviderMessage?: boolean; /** Adapter MUST scrub secrets before populating this field. */ details?: string[] | Record; } diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 9a80ea99b0..6f1c5c9ace 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -196,6 +196,13 @@ export interface RuntimeEventErrorContent { /** Stable machine-readable reason for routing; mirrors ErrorEvent.reason. */ reason?: string; message: string; + /** + * Marks `message` as the allowlisted, redacted, bounded provider summary + * produced by the Runtime provider-failure boundary. Presentation layers + * may render such a message verbatim; an unmarked message must not be + * shown raw even when `code` is present. + */ + boundedProviderMessage?: boolean; /** Adapter MUST scrub secrets before populating this field. */ details?: string[] | Record; } @@ -497,7 +504,7 @@ const FUNCTION_RESPONSE_CONTENT_SHAPE = defineObjectShape()( ['kind', 'message'], - ['code', 'reason', 'details'], + ['code', 'reason', 'boundedProviderMessage', 'details'], ); const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( [], diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts index d08fcea84e..5f8ce6f161 100644 --- a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -438,6 +438,7 @@ describe('AiSdkFlow seam', () => { code: 'AUTH', reason: 'auth_failed', message: 'no token', + boundedProviderMessage: true, }), ev({ type: 'complete', stopReason: 'error' }), ], @@ -447,10 +448,16 @@ describe('AiSdkFlow seam', () => { const err = out[0]; assert.equal(err.content?.kind, 'error'); - const errContent = err.content as { code?: string; reason?: string; message: string }; + const errContent = err.content as { + code?: string; + reason?: string; + message: string; + boundedProviderMessage?: boolean; + }; assert.equal(errContent.message, 'no token'); assert.equal(errContent.code, 'AUTH'); assert.equal(errContent.reason, 'auth_failed'); + assert.equal(errContent.boundedProviderMessage, true); // error event itself is non-terminal; the trailing complete carries failed. assert.equal(isTerminalRuntimeEvent(err), false); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 8705adc838..e85e1cee51 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -690,11 +690,13 @@ describe('ModelAdapter stream and error normalization', () => { retryable: false, code: 'permission_error', message: `${observedMessage} (code=permission_error, status=403)`, + boundedProviderMessage: true, }); const event = adapter.makeErrorEvent('turn-1', failure); assert.equal(event.reason, undefined); assert.equal(event.code, 'permission_error'); assert.equal(event.message, `${observedMessage} (code=permission_error, status=403)`); + assert.equal(event.boundedProviderMessage, true); }); test('normalizes cache and reasoning usage variants in the adapter module', () => { diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 06c3cad86a..a5d3cb7a6f 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -583,6 +583,59 @@ describe('models.dev provider conformance', () => { assert.deepEqual(requestedModels, ['custom-moonshot-preview']); }); + test('connection probe keeps a Kimi permission 403 out of authentication', async () => { + const server = await startJsonServer(async (request, response) => { + assert.equal(request.method, 'POST'); + assert.equal(request.url, '/v1/chat/completions'); + await readBody(request); + respondJson(response, 403, { + error: { + type: 'permission_error', + message: 'You have reached the plan usage limit for this model.', + }, + }); + }); + const connection: LlmConnection = { + slug: 'moonshot-plan-limit', + name: 'Moonshot Plan Limit', + providerType: 'moonshot', + baseUrl: `${server.url}/v1`, + defaultModel: 'kimi-k2.6', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + const result = await testConnection(connection, 'moonshot-key'); + + assert.equal(result.ok, false); + assert.equal(result.statusCode, 403); + assert.equal(result.errorClass, 'unknown'); + }); + + test('connection probe still classifies a bare 401 as authentication', async () => { + const server = await startJsonServer(async (request, response) => { + await readBody(request); + respondJson(response, 401, {}); + }); + const connection: LlmConnection = { + slug: 'moonshot-bare-401', + name: 'Moonshot Bare 401', + providerType: 'moonshot', + baseUrl: `${server.url}/v1`, + defaultModel: 'kimi-k2.6', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + const result = await testConnection(connection, 'moonshot-key'); + + assert.equal(result.ok, false); + assert.equal(result.statusCode, 401); + assert.equal(result.errorClass, 'auth'); + }); + test('OpenAI routes gpt-5* through the Responses wire and other models through Chat Completions by declaration', async () => { const requests: string[] = []; const server = await startJsonServer(async (request, response) => { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index c5a262ae39..e3781824fb 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -528,9 +528,15 @@ describe('Provider error classification', () => { code: 'rate_limit_exceeded', }); assert.equal(classifyError(throttle), 'RateLimit'); - assert.deepEqual(providerRetryMetadata(throttle), { retryable: true }); - assert.deepEqual(providerRetryMetadata(Object.assign(throttle, { isRetryable: false })), { - retryable: false, + // A bare rate limit fails closed; automatic retries need a named delay. + assert.deepEqual(providerRetryMetadata(throttle), { retryable: false }); + const delayedThrottle = Object.assign( + providerError(429, 'Too many requests', { code: 'rate_limit_exceeded' }), + { responseHeaders: { 'retry-after': '40' } }, + ); + assert.deepEqual(providerRetryMetadata(delayedThrottle), { + retryable: true, + retryAfterMs: 40_000, }); assert.equal(classifyError(providerError(401, 'Invalid API key')), 'Auth'); diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts index 671e7c81e8..942e15167b 100644 --- a/packages/runtime/src/ai-sdk-flow.ts +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -579,6 +579,7 @@ function mapBackendSessionEvent( ...(event.code !== undefined ? { code: event.code } : {}), ...(event.reason !== undefined ? { reason: event.reason } : {}), message: event.message, + ...(event.boundedProviderMessage === true ? { boundedProviderMessage: true } : {}), ...(event.details !== undefined ? { details: event.details } : {}), }; memory.failureContent = content; diff --git a/packages/runtime/src/connection-effect-outcome.ts b/packages/runtime/src/connection-effect-outcome.ts index c578d20b4f..9a27f50f10 100644 --- a/packages/runtime/src/connection-effect-outcome.ts +++ b/packages/runtime/src/connection-effect-outcome.ts @@ -18,6 +18,7 @@ */ import type { ModelDiscoverySource, ModelInfo, ProviderType } from '@maka/core/llm-connections'; +import { classifyError } from './provider-error-classification.js'; export interface ConnectionEffectConnection { readonly providerType: ProviderType; @@ -73,10 +74,21 @@ export class ConnectionEffectInvalidResponseError extends Error { } export function classifyConnectionEffectStatus(statusCode: number): ConnectionEffectError { - if (statusCode === 401 || statusCode === 403) return { kind: 'auth', statusCode }; - if (statusCode === 408) return { kind: 'timeout', statusCode }; - if (statusCode === 429 || (statusCode >= 500 && statusCode <= 599)) { - return { kind: 'provider_unavailable', statusCode }; + // Status-only evidence still routes through the shared provider-failure + // authority. A bare 403 is intentionally not authentication: providers use + // it for valid-key permission failures, guardrails, and subscription limits. + switch (classifyError({ statusCode })) { + case 'Auth': + return { kind: 'auth', statusCode }; + case 'RateLimit': + case 'ProviderUnavailable': + case 'ProviderBilling': + case 'ProviderPermission': + case 'UsageLimit': + return { kind: 'provider_unavailable', statusCode }; + default: + break; } + if (statusCode === 408) return { kind: 'timeout', statusCode }; return { kind: 'unknown', statusCode }; } diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 34eeeb3663..2a65609d07 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -439,6 +439,7 @@ export class ModelAdapter { ts: this.input.now(), recoverable: false, ...(failure.code !== undefined ? { code: failure.code } : {}), + ...(failure.boundedProviderMessage === true ? { boundedProviderMessage: true } : {}), ...(failure.kind !== 'abort' && failure.kind !== 'unknown' ? { reason: failure.kind } : {}), message: failure.message, }; @@ -898,10 +899,15 @@ function normalizeProviderFailure(error: unknown): ModelFailure { if (isModelFailure(error)) return error; const summary = providerFailureSummary(error); const failure = normalizeModelFailure(error); + // The bounded summary is display-safe provider wording; a generalized + // presentation message is not. The marker must follow the message, not the + // presence of a code (Error.code and provider codes both exist here). + const boundedProviderMessage = failure.kind === 'unknown' && summary !== undefined; return { ...failure, ...(summary?.code !== undefined ? { code: summary.code } : {}), - ...(failure.kind === 'unknown' && summary !== undefined ? { message: summary.message } : {}), + ...(boundedProviderMessage ? { message: summary.message } : {}), + ...(boundedProviderMessage ? { boundedProviderMessage: true } : {}), }; } diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index 03373c6c07..88ff7d9956 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -351,6 +351,11 @@ export interface ModelFailure { /** Provider-requested delay for the next physical attempt, in milliseconds. */ retryAfterMs?: number; code?: string; + /** + * True when `message` is the bounded, redacted provider summary from the + * provider-failure boundary rather than a generalized error message. + */ + boundedProviderMessage?: boolean; } /** diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index ea51501a20..0c1b330ffc 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -422,22 +422,27 @@ function durableProviderErrorClass(classified: string, httpStatus: number | unde function providerFailureDiagnosticFacts(error: unknown): ProviderErrorFacts | undefined { let current = providerErrorTarget(error); let fallback: ProviderErrorFacts | undefined; + let structuredFallback: ProviderErrorFacts | undefined; let codedFallback: ProviderErrorFacts | undefined; const seen = new Set(); for (let depth = 0; depth < 4 && current !== undefined && !seen.has(current); depth += 1) { seen.add(current); const facts = normalizeProviderError(current); fallback ??= facts; - if (facts && (facts.evidence.statusCode || facts.evidence.structuredCodes.length > 0)) { - return facts; - } + // HTTP status is the strongest durable evidence: an SDK wrapper can carry + // its own transport `code` (for example `FETCH_FAILED`) while the real + // provider status sits on the wrapped cause. Prefer the status-bearing + // fact, and only fall back to wrapper-level structured codes when no fact + // in the chain carries one. + if (facts?.evidence.statusCode) return facts; + structuredFallback ??= facts && facts.evidence.structuredCodes.length > 0 ? facts : undefined; if (facts?.evidence.code) codedFallback ??= facts; current = current && typeof current === 'object' ? safeField(current as Record, 'cause') : undefined; } - return codedFallback ?? fallback; + return structuredFallback ?? codedFallback ?? fallback; } interface ProviderFailureSources { diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 1af8805cf7..9cfb0bfa9f 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -45,6 +45,7 @@ import { type ConnectionEffectError, type ConnectionTestEffectOutcome, } from './connection-effect-outcome.js'; +import { classifyError } from './provider-error-classification.js'; const CONNECTION_TEST_TIMEOUT_MS = 15_000; @@ -469,7 +470,7 @@ async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise= 500) return 'provider_unavailable'; - return 'unknown'; +function classifyHttpFailure(statusCode: number, body: string): ConnectionTestResult['errorClass'] { + // The response body carries the provider's structured envelope; the numeric + // status is the fallback when the body is empty or unstructured. Both flow + // through the one shared provider-failure authority instead of a parallel + // status-only classifier. + const fromBody = classifyError(body); + const fromStatus = classifyError({ statusCode }); + return connectionTestErrorClassFromProviderClass(fromBody !== 'Other' ? fromBody : fromStatus); +} + +function connectionTestErrorClassFromProviderClass(errorClass: string): ConnectionTestErrorClass { + switch (errorClass) { + case 'Auth': + return 'auth'; + case 'Timeout': + return 'timeout'; + case 'Network': + return 'network'; + case 'RateLimit': + case 'ProviderUnavailable': + case 'ProviderBilling': + case 'ProviderPermission': + case 'UsageLimit': + return 'provider_unavailable'; + default: + return 'unknown'; + } } function connectionTestFailure( @@ -514,6 +538,15 @@ function classifyConnectionTestError(error: unknown): ConnectionEffectError { } function classifyConnectionTestResult(result: ConnectionTestResult): ConnectionEffectError { + // The body-aware classification computed at the probe boundary is + // authoritative; the status-only fallback must not override it (a Kimi 403 + // carrying a permission envelope is neutral, not a reauth). + if (result.errorClass !== undefined && result.errorClass !== 'unknown') { + return { + kind: connectionTestErrorKind(result.errorClass), + ...(result.statusCode === undefined ? {} : { statusCode: result.statusCode }), + }; + } if (result.statusCode !== undefined) { const statusError = classifyConnectionEffectStatus(result.statusCode); if (statusError.kind !== 'unknown') return statusError; From 3307210ee169a68bd76b1d1300dddd750b4387a1 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 04/18] fix(runtime-host): preserve provider failure codes in turn snapshots Failed turn snapshots now carry the stable failure code and the bounded provider-summary marker from the canonical terminal error fact, and the session projector forwards both to the projected error event. Desktop can therefore render the bounded provider wording without re-deriving meaning from HTTP status or message text. Generated-by: Maka --- .../canonical-session-projection.test.ts | 3 + .../connection-effect-coordinator.test.ts | 39 +++++++++++ .../src/__tests__/protocol.test.ts | 24 +++++++ .../src/__tests__/session-projector.test.ts | 66 +++++++++++++++++++ .../src/adapter/session-projector.ts | 2 + packages/runtime-host/src/protocol/turn.ts | 19 +++++- .../src/server/canonical-turn-snapshot.ts | 22 +++++-- 7 files changed, 168 insertions(+), 7 deletions(-) diff --git a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts index a0fcd301a4..1ac77f0b17 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -377,6 +377,7 @@ test('projects a failed Turn message from the canonical terminal event', async ( ts: 12, recoverable: false, code: 'provider_error', + boundedProviderMessage: true, message: 'canonical provider failure api_key=sk-test-secret-value', }, context, @@ -417,6 +418,8 @@ test('projects a failed Turn message from the canonical terminal event', async ( canonical.rootTurn.failureMessage, 'canonical provider failure api_key=[redacted]', ); + assert.equal(canonical.rootTurn.failureCode, 'provider_error'); + assert.equal(canonical.rootTurn.boundedProviderMessage, true); } }); }); diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index 46c74659fe..a40eef3ebc 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -749,6 +749,45 @@ test('connection test derives a persisted summary from one bounded projection', }); }); +test('keeps a neutral provider permission failure out of needs_reauth', async () => { + await withFixture(async ({ stores }) => { + const connection = await createConnection(stores, 0, connectionDraft('plan-limit', 'openai')); + await setConnectionCredential(stores, connection, 'test-credential'); + const coordinator = new HostConnectionEffectCoordinator({ + stores, + activation: new RuntimePolicyActivationGate(), + oauthCredentials: new HostOAuthExecutionAuthority(stores), + now: () => Date.parse('2026-07-29T12:00:00.000Z'), + createTransport: () => recordingTransport(() => {}), + runConnectionTest: async (_connection, _apiKey, _options, modelId) => { + assert.equal(modelId, 'gpt-5'); + return { + ok: false, + error: { kind: 'unknown', statusCode: 403 }, + modelId: 'gpt-5', + latencyMs: 17, + }; + }, + }); + + const outcome = await coordinator.handlers['connection.test.run']( + { connectionId: connection.connectionId, modelId: 'gpt-5' }, + context, + ); + + assert.equal(outcome.ok, true); + if (!outcome.ok || outcome.result.kind !== 'committed') { + throw new Error('connection test did not commit'); + } + const persisted = await stores.connectionCatalog.getSnapshot(); + assert.deepEqual(persisted.connections[0]?.lastTest, { + status: 'error', + checkedAt: outcome.result.test.checkedAt, + errorClass: 'unknown', + }); + }); +}); + test('projects credential changes during provider I/O as semantic superseded and closes transport', async () => { await withFixture(async ({ stores }) => { const connection = await createConnection(stores, 0, connectionDraft('superseded', 'openai')); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 146c199f07..d2cca28c39 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1556,6 +1556,8 @@ describe('Runtime Host bootstrap protocol', () => { terminalEventId: 'event-1', failureClass: 'unknown', failureMessage: 'Provider request failed', + failureCode: 'permission_error', + boundedProviderMessage: true, }, }; @@ -1571,6 +1573,28 @@ describe('Runtime Host bootstrap protocol', () => { }), isInvalidFrame, ); + assert.throws( + () => + decodeHostFrame({ + ...response, + result: { + ...response.result, + failureCode: 'x'.repeat(129), + }, + }), + isInvalidFrame, + ); + assert.throws( + () => + decodeHostFrame({ + ...response, + result: { + ...response.result, + boundedProviderMessage: 'true', + }, + }), + isInvalidFrame, + ); }); test('bounds encoded protocol messages', () => { diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index e2138d3f78..7bd9c8ed1f 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -100,6 +100,72 @@ test('reseeds the latest provider retry when the active Turn still carries one', assert.equal(seeded[0] && 'phase' in seeded[0] ? seeded[0].phase : undefined, 'scheduled'); }); +test('projects a failed Turn with its provider code and bounded-summary marker', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const failed = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'failed', + terminalEventId: 'terminal-1', + failureClass: 'permission_error', + failureCode: 'permission_error', + failureMessage: 'bounded provider wording', + boundedProviderMessage: true, + }, + }), + }).events; + const errorEvent = failed.find((event) => event.type === 'error'); + assert.ok(errorEvent && errorEvent.type === 'error'); + assert.equal(errorEvent.code, 'permission_error'); + assert.equal(errorEvent.boundedProviderMessage, true); + assert.equal(errorEvent.message, 'bounded provider wording'); +}); + +test('does not mark an unmarked failed-Turn message as a bounded provider summary', () => { + const projector = new RuntimeHostSessionProjector( + snapshot(), + createRuntimeHostSessionProjectionSeed([], snapshot()), + () => 10, + ); + + const failed = projector.accept({ + kind: 'subscription.session_projection', + hostEpoch: 'host-1', + subscriptionId: 'subscription-1', + sequence: 2, + snapshot: snapshot({ + projectionRevision: 2, + rootTurn: { + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + status: 'failed', + terminalEventId: 'terminal-1', + failureClass: 'ECONNRESET', + failureCode: 'ECONNRESET', + failureMessage: 'raw internal socket text', + }, + }), + }).events; + const errorEvent = failed.find((event) => event.type === 'error'); + assert.ok(errorEvent && errorEvent.type === 'error'); + assert.equal(errorEvent.code, 'ECONNRESET'); + assert.equal(errorEvent.boundedProviderMessage, undefined); +}); + test('emits a live provider retry when the snapshot overlay appears, then drops it after content', () => { const projector = new RuntimeHostSessionProjector( snapshot(), diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 09760af34a..467865da7a 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -419,6 +419,8 @@ export class RuntimeHostSessionProjector { recoverable: false, reason: root.failureClass, message: root.failureMessage ?? `Turn failed: ${root.failureClass}`, + ...(root.failureCode !== undefined ? { code: root.failureCode } : {}), + ...(root.boundedProviderMessage === true ? { boundedProviderMessage: true } : {}), }); } else { events.push({ diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 100cb6c230..dbfacaa2d6 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -193,6 +193,10 @@ export type TurnSnapshot = terminalEventId: string; failureClass: string; failureMessage?: string; + /** Stable provider/transport code preserved from the terminal error fact. */ + failureCode?: string; + /** Marks `failureMessage` as a safe, bounded provider summary. */ + boundedProviderMessage?: boolean; }) | (TurnSnapshotBase & { status: 'cancelled'; @@ -333,6 +337,11 @@ export const TURN_OPERATION_SPECS = { }), } as const; +function decodeBoundedProviderMessage(value: unknown): boolean { + if (typeof value !== 'boolean') throw invalidProtocolFrame('Invalid boundedProviderMessage'); + return value; +} + function decodeTurnStartInput(value: unknown): TurnStartInput { const record = requireShapedRecord( value, @@ -660,13 +669,16 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { record, 'failed Turn snapshot', ['sessionId', 'turnId', 'runId', 'status', 'terminalEventId', 'failureClass'], - ['failureMessage'], + ['failureMessage', 'failureCode', 'boundedProviderMessage'], ); return { ...base, status, terminalEventId: requireId(record.terminalEventId, 'terminalEventId'), failureClass: requireString(record.failureClass, 'failureClass', 128), + ...(record.failureCode !== undefined + ? { failureCode: requireString(record.failureCode, 'failureCode', 128) } + : {}), ...(record.failureMessage !== undefined ? { failureMessage: requireUtf8String( @@ -677,6 +689,11 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot { ), } : {}), + ...(record.boundedProviderMessage !== undefined + ? { + boundedProviderMessage: decodeBoundedProviderMessage(record.boundedProviderMessage), + } + : {}), }; } if (status === 'cancelled') { diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index 24cf1f3e7a..1f2fc15bf6 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -70,14 +70,20 @@ export async function readCanonicalTurnSnapshot( } if (fact.runStatus === 'failed') { if (!fact.failureClass) throw new Error('Failed terminal fact has no failure class'); + const errorContent = fact.terminalEvent.content; const failureMessage = - fact.terminalEvent.content?.kind === 'error' - ? truncateUtf8( - redactSecrets(fact.terminalEvent.content.message), - TURN_FAILURE_MESSAGE_MAX_BYTES, - '…', - ) + errorContent?.kind === 'error' + ? truncateUtf8(redactSecrets(errorContent.message), TURN_FAILURE_MESSAGE_MAX_BYTES, '…') : undefined; + const failureCode = + errorContent?.kind === 'error' && + typeof errorContent.code === 'string' && + errorContent.code.length > 0 && + errorContent.code.length <= 128 + ? errorContent.code + : undefined; + const boundedProviderMessage = + errorContent?.kind === 'error' && errorContent.boundedProviderMessage === true; return { sessionId, turnId, @@ -85,7 +91,9 @@ export async function readCanonicalTurnSnapshot( status: 'failed', terminalEventId: fact.terminalEvent.id, failureClass: fact.failureClass, + ...(failureCode !== undefined ? { failureCode } : {}), ...(failureMessage ? { failureMessage } : {}), + ...(boundedProviderMessage ? { boundedProviderMessage: true } : {}), }; } if (!fact.abortSource) throw new Error('Cancelled terminal fact has no abort source'); @@ -132,7 +140,9 @@ export function worstCaseFailedTurnSnapshot(identity: CanonicalTurnIdentity): Tu status: 'failed', terminalEventId: 'x'.repeat(128), failureClass: '\0'.repeat(128), + failureCode: '\0'.repeat(128), failureMessage: '\0'.repeat(TURN_FAILURE_MESSAGE_MAX_BYTES), + boundedProviderMessage: true, }; } From b05e995c8552dc62942ae5a6186c3b67c1187a14 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 05/18] fix(desktop): render only bounded provider summaries verbatim The error-toast fallback now requires the bounded provider-summary marker before showing a message raw. A bare code is no longer enough, since Node transport codes carry unbounded internal text. Generated-by: Maka --- .../provider-failure-presentation.test.ts | 20 +++++++++++++++++-- .../src/renderer/model-connection-errors.ts | 12 +++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index f690ee00b3..71e289e881 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -50,6 +50,7 @@ describe('provider failure presentation', () => { ts: 1, recoverable: false, code: 'permission_error', + boundedProviderMessage: true, message, }; @@ -57,6 +58,21 @@ describe('provider failure presentation', () => { assert.equal(sessionEventErrorMessage(event, 'en'), message); }); + test('does not render a coded message verbatim without the bounded-provider marker', () => { + const event: Extract = { + type: 'error', + id: 'event-ecodes', + turnId: 'turn-ecodes', + ts: 1, + recoverable: false, + code: 'ECONNRESET', + message: 'socket hang up at internal-connect.ts:42 (raw internal text)', + }; + + assert.equal(sessionEventErrorMessage(event), '任务运行失败,请稍后重试。'); + assert.equal(sessionEventErrorMessage(event, 'en'), 'The task run failed. Try again later.'); + }); + test('uses generic copy when an error has neither a known reason nor provider evidence', () => { const event: Extract = { type: 'error', @@ -67,7 +83,7 @@ describe('provider failure presentation', () => { message: '403 permission denied', }; - assert.equal(sessionEventErrorMessage(event), '对话运行失败,请稍后重试。'); - assert.equal(sessionEventErrorMessage(event, 'en'), 'The conversation run failed. Try again later.'); + assert.equal(sessionEventErrorMessage(event), '任务运行失败,请稍后重试。'); + assert.equal(sessionEventErrorMessage(event, 'en'), 'The task run failed. Try again later.'); }); }); diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index 4677f6c8fe..79b607c18b 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -65,8 +65,16 @@ export function sessionEventErrorMessage( // Provider errors reach this boundary with a stable code plus the // allowlisted, redacted, bounded summary produced by ModelAdapter. Keep that // structured result authoritative instead of reclassifying words or HTTP - // status fragments in the presentation layer. - if (event.code !== undefined && event.message.length > 0) return event.message; + // status fragments in the presentation layer. The bounded marker is the only + // proof that `message` is safe to render verbatim: a code alone can be a Node + // transport code whose message is unbounded internal text. + if ( + event.boundedProviderMessage === true && + event.code !== undefined && + event.message.length > 0 + ) { + return event.message; + } const fallback = getDesktopConversationCopy(locale).actions.conversationErrorFallback; return fallback; From b5da20f40393d0929d876f4d6a231a3e84fd604a Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 19:02:47 +0800 Subject: [PATCH 06/18] fix(runtime): unify provider failure authority Generated-by: Maka --- .../provider-failure-presentation.test.ts | 43 ++++++++++ .../runtime-host-connections-ipc-main.test.ts | 32 +++++++ .../main/runtime-host-connections-ipc-main.ts | 3 + apps/desktop/src/renderer/app-shell-copy.ts | 29 +++++-- .../settings/provider-panel-shared.ts | 33 +++++--- packages/core/package.json | 1 + packages/core/src/llm-connections.ts | 1 + packages/core/src/provider-failure.ts | 37 +++++++++ .../connection-effect-coordinator.test.ts | 24 +++++- .../connection-effects-protocol.test.ts | 29 +++++++ .../src/protocol/connection-effects.ts | 66 +++++++++++++++ .../server/connection-effect-coordinator.ts | 3 + .../__tests__/provider-conformance.test.ts | 39 +++++++++ .../provider-error-classification.test.ts | 31 +++++++ .../provider-request-telemetry.test.ts | 10 +-- .../runtime/src/connection-effect-outcome.ts | 15 ++-- packages/runtime/src/model-adapter.ts | 21 +++-- .../src/provider-error-classification.ts | 83 +++++++++++++------ packages/runtime/src/test-connection.ts | 36 +++----- 19 files changed, 445 insertions(+), 91 deletions(-) create mode 100644 packages/core/src/provider-failure.ts diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index 71e289e881..0dd62da936 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -8,6 +8,8 @@ import { deriveFailedTurnRecovery, describeTurnErrorClass, } from '../../renderer/session-status-presentation.js'; +import { commandPaletteConnectionTestFailureMessage } from '../../renderer/app-shell-copy.js'; +import { connectionTestFailureMessage } from '../../renderer/settings/provider-panel-shared.js'; describe('provider failure presentation', () => { test('keeps provider account and access failures distinct in both locales', () => { @@ -86,4 +88,45 @@ describe('provider failure presentation', () => { assert.equal(sessionEventErrorMessage(event), '任务运行失败,请稍后重试。'); assert.equal(sessionEventErrorMessage(event, 'en'), 'The task run failed. Try again later.'); }); + + test('does not reclassify a neutral connection-test 403 as authentication', () => { + const result = { + ok: false, + statusCode: 403, + errorClass: 'unknown' as const, + errorMessage: '403 permission_error usage limit', + }; + + assert.equal( + connectionTestFailureMessage(result, { + auth: 'AUTH SHOULD NOT WIN', + recheck: 'RECHECK', + }, 'en'), + 'RECHECK', + ); + assert.notEqual(commandPaletteConnectionTestFailureMessage(result, 'en'), 'Authentication failed. Check the model key, subscription login, or credentials and try again.'); + }); + + test('renders only the Runtime-marked connection-test provider summary verbatim', () => { + const message = 'Plan allowance exhausted. (code=permission_error, status=403)'; + const result = { + ok: false, + statusCode: 403, + errorClass: 'unknown' as const, + providerFailure: { + errorClass: 'RequestRejected' as const, + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message, + boundedProviderMessage: true as const, + }, + }; + + assert.equal( + connectionTestFailureMessage(result, { auth: 'AUTH', recheck: 'RECHECK' }, 'en'), + message, + ); + assert.equal(commandPaletteConnectionTestFailureMessage(result, 'en'), message); + }); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts index 7eda0a88ce..9909edc5d3 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts @@ -349,6 +349,38 @@ test('preserves the Host-tested model and diagnostics for the existing Desktop U errorClass: 'provider_unavailable', }, ); + const providerFailure = { + errorClass: 'RequestRejected' as const, + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: 'Plan allowance exhausted. (code=permission_error, status=403)', + boundedProviderMessage: true as const, + }; + assert.deepEqual( + projectHostConnectionTest({ + kind: 'committed', + catalogRevision: 10, + connection: { connectionId: 'connection-1', revision: 7 }, + test: { + kind: 'failed', + checkedAt: '2026-08-05T00:00:02.000Z', + modelId: 'model-1', + latencyMs: 300, + statusCode: 403, + errorClass: 'unknown', + providerFailure, + }, + }), + { + ok: false, + modelTested: 'model-1', + latencyMs: 300, + statusCode: 403, + errorClass: 'unknown', + providerFailure, + }, + ); }); function catalog(): ConnectionCatalogSnapshot { diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts index 64716cf624..2f501ecb26 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -322,6 +322,9 @@ export function projectHostConnectionTest(result: ConnectionTestRunResult): Conn errorClass: result.test.errorClass === 'invalid_response' ? 'unknown' : result.test.errorClass, + ...(result.test.providerFailure === undefined + ? {} + : { providerFailure: result.test.providerFailure }), }; } diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 78da030fd6..9861dc415c 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -60,21 +60,32 @@ export function openPathActionErrorMessage( export function commandPaletteConnectionTestFailureMessage(result: ConnectionTestResult, locale: UiLocale): string { const fallback = commandPaletteConnectionTestFailureFallback(result, locale); - if (!result.errorMessage) return fallback; - return localizedErrorMessage(new Error(result.errorMessage), fallback, locale); + const failure = result.providerFailure; + return failure?.boundedProviderMessage === true && failure.message + ? failure.message + : fallback; } function commandPaletteConnectionTestFailureFallback(result: ConnectionTestResult, locale: UiLocale): string { const copy = getShellCopy(locale).commandActions.connectionFailures; - if (result.statusCode === 429) return copy.rateLimit; - if (result.errorClass === 'timeout') return copy.timeout; - if (result.errorClass === 'auth' || result.statusCode === 401 || result.statusCode === 403) { - return copy.auth; + switch (result.providerFailure?.errorClass) { + case 'Auth': + return copy.auth; + case 'Timeout': + return copy.timeout; + case 'RateLimit': + return copy.rateLimit; + case 'Network': + return copy.network; + case 'ProviderUnavailable': + return copy.provider; + default: + break; } + if (result.errorClass === 'timeout') return copy.timeout; + if (result.errorClass === 'auth') return copy.auth; if (result.errorClass === 'network') return copy.network; - if (result.errorClass === 'provider_unavailable' || (result.statusCode && result.statusCode >= 500)) { - return copy.provider; - } + if (result.errorClass === 'provider_unavailable') return copy.provider; return copy.unknown; } diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts index ec77a98ce6..35b4c7bf47 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -77,7 +77,7 @@ export function providerPanelActionErrorMessage(error: unknown, locale: UiLocale } export interface ConnectionTestTroubleshootingCopy { - /** Auth-class failure copy (errorClass 'auth' or HTTP 401/403). */ + /** Auth-class failure copy from the Runtime-owned structured result. */ auth: string; /** Final fallback copy when no failure class matched. */ recheck: string; @@ -92,14 +92,23 @@ export function connectionTestFailureFallback( locale: UiLocale = 'zh', ): string { const shared = getProviderSettingsCopy(locale).shared; - if (result.statusCode === 429) return shared.rateLimit; - if (result.errorClass === 'timeout') return shared.timeout; - if (result.errorClass === 'auth' || result.statusCode === 401 || result.statusCode === 403) { - return copy.auth; - } - if (result.errorClass === 'provider_unavailable' || (result.statusCode !== undefined && result.statusCode >= 500)) { - return shared.unavailable; + switch (result.providerFailure?.errorClass) { + case 'Auth': + return copy.auth; + case 'Timeout': + return shared.timeout; + case 'RateLimit': + return shared.rateLimit; + case 'Network': + return shared.network; + case 'ProviderUnavailable': + return shared.unavailable; + default: + break; } + if (result.errorClass === 'auth') return copy.auth; + if (result.errorClass === 'timeout') return shared.timeout; + if (result.errorClass === 'provider_unavailable') return shared.unavailable; if (result.errorClass === 'network') return shared.network; return copy.recheck; } @@ -110,10 +119,10 @@ export function connectionTestFailureMessage( locale: UiLocale = 'zh', ): string { const fallback = connectionTestFailureFallback(result, copy, locale); - if (!result.errorMessage) return fallback; - return locale === 'zh' - ? generalizedErrorMessageChinese(new Error(result.errorMessage), fallback) - : generalizedErrorMessage(new Error(result.errorMessage), fallback); + const failure = result.providerFailure; + return failure?.boundedProviderMessage === true && failure.message + ? failure.message + : fallback; } export function connectionLastTestMessageDisplay(message: string | undefined, locale: UiLocale = 'zh'): string | undefined { diff --git a/packages/core/package.json b/packages/core/package.json index 33a91f50ea..566c61039b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -89,6 +89,7 @@ "./chat-model-choice": "./dist/chat-model-choice.js", "./connection-readiness": "./dist/connection-readiness.js", "./provider-auth": "./dist/provider-auth.js", + "./provider-failure": "./dist/provider-failure.js", "./oauth-subscription": "./dist/oauth-subscription.js", "./onboarding": "./dist/onboarding.js", "./onboarding-milestone": "./dist/onboarding-milestone.js", diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 75bbe31742..7e64a8353a 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -446,6 +446,7 @@ export interface ConnectionTestResult { errorMessage?: string; statusCode?: number; errorClass?: ConnectionTestErrorClass; + providerFailure?: import('./provider-failure.js').ProviderFailureResult; } export const PROVIDER_DEFAULTS = PROVIDER_REGISTRY; diff --git a/packages/core/src/provider-failure.ts b/packages/core/src/provider-failure.ts new file mode 100644 index 0000000000..1e3e57868d --- /dev/null +++ b/packages/core/src/provider-failure.ts @@ -0,0 +1,37 @@ +export const PROVIDER_FAILURE_CLASSES = [ + 'Abort', + 'Auth', + 'ContextLength', + 'Network', + 'Other', + 'ProviderBilling', + 'ProviderPermission', + 'ProviderUnavailable', + 'RateLimit', + 'RequestRejected', + 'Timeout', + 'UsageLimit', +] as const; + +export type ProviderFailureClass = (typeof PROVIDER_FAILURE_CLASSES)[number]; + +/** + * One provider-owned failure interpretation produced at the Runtime boundary. + * Consumers may project or persist these fields, but must not rebuild the + * taxonomy from HTTP status or message text. + */ +export interface ProviderFailureResult { + readonly errorClass: ProviderFailureClass; + readonly retryable: boolean; + readonly retryAfterMs?: number; + readonly httpStatus?: number; + readonly providerCode?: string; + readonly providerRequestId?: string; + readonly message?: string; + /** Proves that `message` was allowlisted, redacted, and bounded by Runtime. */ + readonly boundedProviderMessage?: true; +} + +export function isProviderFailureClass(value: unknown): value is ProviderFailureClass { + return (PROVIDER_FAILURE_CLASSES as readonly unknown[]).includes(value); +} diff --git a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts index a40eef3ebc..e3006138a8 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -763,7 +763,18 @@ test('keeps a neutral provider permission failure out of needs_reauth', async () assert.equal(modelId, 'gpt-5'); return { ok: false, - error: { kind: 'unknown', statusCode: 403 }, + error: { + kind: 'unknown', + statusCode: 403, + providerFailure: { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: 'Plan allowance exhausted. (code=permission_error, status=403)', + boundedProviderMessage: true, + }, + }, modelId: 'gpt-5', latencyMs: 17, }; @@ -779,6 +790,17 @@ test('keeps a neutral provider permission failure out of needs_reauth', async () if (!outcome.ok || outcome.result.kind !== 'committed') { throw new Error('connection test did not commit'); } + assert.deepEqual( + outcome.result.test.kind === 'failed' ? outcome.result.test.providerFailure : undefined, + { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: 'Plan allowance exhausted. (code=permission_error, status=403)', + boundedProviderMessage: true, + }, + ); const persisted = await stores.connectionCatalog.getSnapshot(); assert.deepEqual(persisted.connections[0]?.lastTest, { status: 'error', diff --git a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts index dc143e198a..d5313e4251 100644 --- a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts @@ -156,6 +156,14 @@ describe('Runtime Host connection effects protocol', () => { latencyMs: 42, statusCode: 401, errorClass: 'auth', + providerFailure: { + errorClass: 'Auth', + retryable: false, + httpStatus: 401, + providerCode: 'invalid_api_key', + message: 'Invalid API key. (code=invalid_api_key, status=401)', + boundedProviderMessage: true, + }, }, }; const failed = response('connection.test.run', failedResult); @@ -191,6 +199,27 @@ describe('Runtime Host connection effects protocol', () => { ...failedResult, test: { ...failedResult.test, statusCode: 600 }, }); + assertInvalidResponse('connection.test.run', { + ...failedResult, + test: { + ...failedResult.test, + providerFailure: { + ...failedResult.test.providerFailure, + boundedProviderMessage: true, + message: undefined, + }, + }, + }); + assertInvalidResponse('connection.test.run', { + ...failedResult, + test: { + ...failedResult.test, + providerFailure: { + ...failedResult.test.providerFailure, + boundedProviderMessage: undefined, + }, + }, + }); }); }); diff --git a/packages/runtime-host/src/protocol/connection-effects.ts b/packages/runtime-host/src/protocol/connection-effects.ts index f4cec26037..52a24153e0 100644 --- a/packages/runtime-host/src/protocol/connection-effects.ts +++ b/packages/runtime-host/src/protocol/connection-effects.ts @@ -29,6 +29,7 @@ import { type ModelDiscoverySource, } from '@maka/core/runtime-policy'; import type { ModelInfo, ProviderType } from '@maka/core/llm-connections'; +import { isProviderFailureClass, type ProviderFailureResult } from '@maka/core/provider-failure'; import { requireCount, requireEntityId, @@ -157,6 +158,7 @@ export type ConnectionTestProjection = readonly latencyMs: number | null; readonly statusCode: number | null; readonly errorClass: ConnectionEffectFailureClass; + readonly providerFailure?: ProviderFailureResult; }; export type ConnectionTestRunResult = @@ -412,6 +414,7 @@ function decodeConnectionTestProjection(value: unknown): ConnectionTestProjectio 'latencyMs', 'statusCode', 'errorClass', + ...(Object.hasOwn(projection, 'providerFailure') ? ['providerFailure'] : []), ]); if (failed.kind !== 'failed') throw invalidProtocolFrame('Invalid connection test projection'); return { @@ -426,6 +429,69 @@ function decodeConnectionTestProjection(value: unknown): ConnectionTestProjectio ? null : boundedInteger(failed.statusCode, 'connection test status code', 100, 599), errorClass: effectFailureClass(failed.errorClass), + ...(failed.providerFailure === undefined + ? {} + : { providerFailure: decodeProviderFailureResult(failed.providerFailure) }), + }; +} + +function decodeProviderFailureResult(value: unknown): ProviderFailureResult { + const candidate = requireRecord(value, 'provider failure result'); + const record = requireExactRecord(candidate, 'provider failure result', [ + 'errorClass', + 'retryable', + ...(Object.hasOwn(candidate, 'retryAfterMs') ? ['retryAfterMs'] : []), + ...(Object.hasOwn(candidate, 'httpStatus') ? ['httpStatus'] : []), + ...(Object.hasOwn(candidate, 'providerCode') ? ['providerCode'] : []), + ...(Object.hasOwn(candidate, 'providerRequestId') ? ['providerRequestId'] : []), + ...(Object.hasOwn(candidate, 'message') ? ['message'] : []), + ...(Object.hasOwn(candidate, 'boundedProviderMessage') ? ['boundedProviderMessage'] : []), + ]); + if (!isProviderFailureClass(record.errorClass)) { + throw invalidProtocolFrame('Invalid provider failure class'); + } + if (typeof record.retryable !== 'boolean') { + throw invalidProtocolFrame('Invalid provider failure retryability'); + } + if (record.boundedProviderMessage !== undefined && record.boundedProviderMessage !== true) { + throw invalidProtocolFrame('Invalid bounded provider message marker'); + } + if (record.boundedProviderMessage === true && record.message === undefined) { + throw invalidProtocolFrame('Bounded provider message marker requires a message'); + } + if (record.message !== undefined && record.boundedProviderMessage !== true) { + throw invalidProtocolFrame('Provider failure message requires bounded provenance'); + } + return { + errorClass: record.errorClass, + retryable: record.retryable, + ...(record.retryAfterMs === undefined + ? {} + : { + retryAfterMs: boundedInteger( + record.retryAfterMs, + 'provider retry delay', + 1, + 2_147_483_647, + ), + }), + ...(record.httpStatus === undefined + ? {} + : { + httpStatus: boundedInteger(record.httpStatus, 'provider HTTP status', 100, 599), + }), + ...(record.providerCode === undefined + ? {} + : { providerCode: requireString(record.providerCode, 'provider code', 256) }), + ...(record.providerRequestId === undefined + ? {} + : { + providerRequestId: requireString(record.providerRequestId, 'provider request id', 256), + }), + ...(record.message === undefined + ? {} + : { message: requireString(record.message, 'provider failure message', 2_048) }), + ...(record.boundedProviderMessage === true ? { boundedProviderMessage: true } : {}), }; } diff --git a/packages/runtime-host/src/server/connection-effect-coordinator.ts b/packages/runtime-host/src/server/connection-effect-coordinator.ts index 132a6f5317..11f20feb40 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -488,6 +488,9 @@ function projectConnectionTest( latencyMs: outcome.latencyMs ?? null, statusCode: outcome.error.statusCode ?? null, errorClass: outcome.error.kind, + ...(outcome.error.providerFailure === undefined + ? {} + : { providerFailure: outcome.error.providerFailure }), }; } diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index a5d3cb7a6f..a53e13540f 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -611,6 +611,45 @@ describe('models.dev provider conformance', () => { assert.equal(result.ok, false); assert.equal(result.statusCode, 403); assert.equal(result.errorClass, 'unknown'); + assert.deepEqual(result.providerFailure, { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: + 'You have reached the plan usage limit for this model. (code=permission_error, status=403)', + boundedProviderMessage: true, + }); + }); + + test('connection probe distinguishes a structured usage limit from transient 429', async () => { + const server = await startJsonServer(async (request, response) => { + await readBody(request); + respondJson(response, 429, { + error: { + type: 'usage_limit_reached', + message: 'Your plan allowance is exhausted.', + }, + }); + }); + const connection: LlmConnection = { + slug: 'moonshot-usage-limit', + name: 'Moonshot Usage Limit', + providerType: 'moonshot', + baseUrl: `${server.url}/v1`, + defaultModel: 'kimi-k2.6', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + const result = await testConnection(connection, 'moonshot-key'); + + assert.equal(result.ok, false); + assert.equal(result.errorClass, 'provider_unavailable'); + assert.equal(result.providerFailure?.errorClass, 'UsageLimit'); + assert.equal(result.providerFailure?.retryable, false); + assert.equal(result.providerFailure?.boundedProviderMessage, true); }); test('connection probe still classifies a bare 401 as authentication', async () => { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index e3781824fb..5df85a2410 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -26,6 +26,7 @@ import { z } from 'zod/v4'; import { classifyError, providerFailureDiagnostic, + providerFailureResult, errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, @@ -90,6 +91,22 @@ describe('Provider error classification', () => { [Object.assign(new Error('bad request'), { statusCode: 400 }), 'RequestRejected'], [Object.assign(new Error('slow down'), { statusCode: 429 }), 'RateLimit'], [Object.assign(new Error('upstream failed'), { statusCode: 503 }), 'ProviderUnavailable'], + [ + Object.assign(new Error('access denied'), { + statusCode: 403, + data: { error: { type: 'permission_denied' } }, + }), + 'ProviderPermission', + ], + [ + Object.assign(new Error('plan exhausted'), { + statusCode: 429, + data: { error: { type: 'usage_limit_reached' } }, + }), + 'UsageLimit', + ], + [{ error: { type: 'permission_denied' } }, 'ProviderPermission'], + [{ error: { type: 'usage_limit_reached' } }, 'UsageLimit'], [new DOMException('request timed out', 'TimeoutError'), 'Timeout'], [new TypeError('fetch failed'), 'Network'], [ @@ -580,6 +597,20 @@ describe('Provider error classification', () => { message: `${observedMessage} (code=permission_error, status=403)`, code: 'permission_error', }); + assert.deepEqual(providerFailureResult(planCycleLimit), { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + message: `${observedMessage} (code=permission_error, status=403)`, + boundedProviderMessage: true, + }); + assert.deepEqual(providerFailureDiagnostic(planCycleLimit), { + errorClass: 'RequestRejected', + httpStatus: 403, + providerCode: 'permission_error', + retryable: false, + }); }); test('maps provider classes to stable user-safe presentations', () => { diff --git a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts index 9ef2d8412e..9543127abe 100644 --- a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts +++ b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts @@ -1010,7 +1010,7 @@ describe('canonical model-call accounting', () => { name: 'AI_APICallError', statusCode: 429, data: { - error: { code: 'rate_limit_exceeded', message: 'private response body' }, + error: { code: 'usage_limit_reached', message: 'private response body' }, }, responseHeaders: { 'x-request-id': 'req-compact-1' }, requestBodyValues: { input: 'private request body' }, @@ -1030,15 +1030,15 @@ describe('canonical model-call accounting', () => { const attempt = decodeModelCallAttempt(recorded[0]); assert.equal(attempt.historyCompactRoute, 'provider_native'); - assert.equal(attempt.errorClass, 'RateLimit'); + assert.equal(attempt.errorClass, 'UsageLimit'); assert.equal(attempt.httpStatus, 429); - assert.equal(attempt.providerCode, 'rate_limit_exceeded'); + assert.equal(attempt.providerCode, 'usage_limit_reached'); assert.equal(attempt.providerRequestId, 'req-compact-1'); assert.equal(attempt.retryable, false); assert.deepEqual(diagnosticAttempts[0]?.failure, { - errorClass: 'RateLimit', + errorClass: 'UsageLimit', httpStatus: 429, - providerCode: 'rate_limit_exceeded', + providerCode: 'usage_limit_reached', providerRequestId: 'req-compact-1', retryable: false, }); diff --git a/packages/runtime/src/connection-effect-outcome.ts b/packages/runtime/src/connection-effect-outcome.ts index 9a27f50f10..002d8650cd 100644 --- a/packages/runtime/src/connection-effect-outcome.ts +++ b/packages/runtime/src/connection-effect-outcome.ts @@ -18,7 +18,8 @@ */ import type { ModelDiscoverySource, ModelInfo, ProviderType } from '@maka/core/llm-connections'; -import { classifyError } from './provider-error-classification.js'; +import type { ProviderFailureResult } from '@maka/core/provider-failure'; +import { providerFailureResult } from './provider-error-classification.js'; export interface ConnectionEffectConnection { readonly providerType: ProviderType; @@ -40,6 +41,7 @@ export type ConnectionEffectErrorKind = export interface ConnectionEffectError { readonly kind: ConnectionEffectErrorKind; readonly statusCode?: number; + readonly providerFailure?: ProviderFailureResult; } export type ConnectionModelDiscoveryEffectOutcome = @@ -77,18 +79,19 @@ export function classifyConnectionEffectStatus(statusCode: number): ConnectionEf // Status-only evidence still routes through the shared provider-failure // authority. A bare 403 is intentionally not authentication: providers use // it for valid-key permission failures, guardrails, and subscription limits. - switch (classifyError({ statusCode })) { + const providerFailure = providerFailureResult({ statusCode }); + switch (providerFailure.errorClass) { case 'Auth': - return { kind: 'auth', statusCode }; + return { kind: 'auth', statusCode, providerFailure }; case 'RateLimit': case 'ProviderUnavailable': case 'ProviderBilling': case 'ProviderPermission': case 'UsageLimit': - return { kind: 'provider_unavailable', statusCode }; + return { kind: 'provider_unavailable', statusCode, providerFailure }; default: break; } - if (statusCode === 408) return { kind: 'timeout', statusCode }; - return { kind: 'unknown', statusCode }; + if (statusCode === 408) return { kind: 'timeout', statusCode, providerFailure }; + return { kind: 'unknown', statusCode, providerFailure }; } diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 2a65609d07..4d815aa66c 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -59,7 +59,7 @@ import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime. import { classifyError, errorPresentationFromClass, - providerFailureSummary, + providerFailureResult, providerRetryMetadata, } from './provider-error-classification.js'; import type { ProviderRequestTracker } from './provider-request-telemetry.js'; @@ -897,16 +897,23 @@ function normalizeModelFailure(error: unknown): ModelFailure { function normalizeProviderFailure(error: unknown): ModelFailure { if (isModelFailure(error)) return error; - const summary = providerFailureSummary(error); - const failure = normalizeModelFailure(error); + const result = providerFailureResult(error); + const presentation = errorPresentationFromClass(result.errorClass); // The bounded summary is display-safe provider wording; a generalized // presentation message is not. The marker must follow the message, not the // presence of a code (Error.code and provider codes both exist here). - const boundedProviderMessage = failure.kind === 'unknown' && summary !== undefined; + const kind = modelFailureKind(result.errorClass); + const boundedProviderMessage = kind === 'unknown' && result.boundedProviderMessage === true; return { - ...failure, - ...(summary?.code !== undefined ? { code: summary.code } : {}), - ...(boundedProviderMessage ? { message: summary.message } : {}), + type: 'model_failure', + kind, + retryable: result.retryable, + ...(result.retryAfterMs !== undefined ? { retryAfterMs: result.retryAfterMs } : {}), + ...(result.providerCode !== undefined ? { code: result.providerCode } : {}), + message: + boundedProviderMessage && result.message + ? result.message + : (presentation.message ?? generalizedErrorMessage(error)), ...(boundedProviderMessage ? { boundedProviderMessage: true } : {}), }; } diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 0c1b330ffc..192376b05d 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -19,6 +19,7 @@ import { RetryError } from 'ai'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { isProviderFailureClass, type ProviderFailureResult } from '@maka/core/provider-failure'; import { isAuthenticationErrorText, redactSecrets } from '@maka/core/redaction'; /** @@ -101,14 +102,11 @@ export interface ProviderRetryMetadata { retryAfterMs?: number; } -/** Bounded, allowlisted provider failure facts safe for durable telemetry. */ -export interface ProviderFailureDiagnostic { - errorClass: string; - httpStatus?: number; - providerCode?: string; - providerRequestId?: string; - retryable: boolean; -} +/** Bounded provider facts safe for durable telemetry (no presentation text). */ +export type ProviderFailureDiagnostic = Pick< + ProviderFailureResult, + 'errorClass' | 'httpStatus' | 'providerCode' | 'providerRequestId' | 'retryable' +>; interface ProviderFailureSummary { message: string; @@ -165,6 +163,10 @@ function parseRetryAfterMs(headers: Record): number | null | und export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { const facts = normalizeProviderError(error); if (!facts) return { retryable: false }; + return providerRetryMetadataFromFacts(facts); +} + +function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetryMetadata { const { evidence } = facts; if (RUNTIME_RETRYABLE_ERROR_CODES.has(evidence.code)) return { retryable: true }; @@ -297,6 +299,11 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined }; const structuredCodes: string[] = []; collectStructuredCodes(record, structuredCodes); + const rawBody = safeField(record, 'responseBody'); + if (typeof rawBody === 'string') { + const parsedBody = parsedProviderValue(rawBody); + if (parsedBody !== undefined) collectStructuredCodes(parsedBody, structuredCodes); + } let text: string; try { // Serialize the whole value so message/code text is evidence no matter @@ -328,6 +335,12 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined export function providerFailureSummary(error: unknown): ProviderFailureSummary | undefined { const facts = normalizeProviderError(error); if (!facts) return undefined; + return providerFailureSummaryFromFacts(facts); +} + +function providerFailureSummaryFromFacts( + facts: ProviderErrorFacts, +): ProviderFailureSummary | undefined { const sources = facts.summarySources; const message = firstProviderMessage(facts); const code = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); @@ -357,24 +370,29 @@ export function providerFailureSummary(error: unknown): ProviderFailureSummary | }; } -const DURABLE_PROVIDER_ERROR_CLASSES: ReadonlySet = new Set([ - 'Abort', - 'Auth', - 'ContextLength', - 'Network', - 'ProviderCapacity', - 'ProviderBilling', - 'ProviderUnavailable', - 'RateLimit', - 'Timeout', -]); - /** * Projects provider errors into a small durable fingerprint. Unlike the * presentation summary, this intentionally excludes provider messages and * response bodies: even redacted free text can echo prompts or credentials. */ export function providerFailureDiagnostic(error: unknown): ProviderFailureDiagnostic { + const failure = providerFailureResult(error); + return { + errorClass: failure.errorClass, + ...(failure.httpStatus !== undefined ? { httpStatus: failure.httpStatus } : {}), + ...(failure.providerCode !== undefined ? { providerCode: failure.providerCode } : {}), + ...(failure.providerRequestId !== undefined + ? { providerRequestId: failure.providerRequestId } + : {}), + retryable: failure.retryable, + }; +} + +/** The single structured provider-failure authority for Runtime consumers. */ +export function providerFailureResult(error: unknown): ProviderFailureResult { + if (RetryError.isInstance(error) && error.reason === 'abort') { + return { errorClass: 'Abort', retryable: false }; + } const facts = providerFailureDiagnosticFacts(error); if (!facts) return { errorClass: 'Other', retryable: false }; const sources = facts.summarySources; @@ -386,25 +404,36 @@ export function providerFailureDiagnostic(error: unknown): ProviderFailureDiagno ? numericStatus : undefined; const classified = classifyProviderFacts(facts); - const errorClass = durableProviderErrorClass(classified, httpStatus); + const errorClass = normalizedProviderFailureClass(classified, httpStatus); const providerCode = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? boundedProviderField(facts.responseHeaders?.['x-request-id']); + const retry = providerRetryMetadataFromFacts(facts); + const summary = providerFailureSummaryFromFacts(facts); return { errorClass, ...(httpStatus !== undefined ? { httpStatus } : {}), ...(providerCode !== undefined ? { providerCode } : {}), ...(providerRequestId !== undefined ? { providerRequestId } : {}), - retryable: providerRetryMetadata(facts.target).retryable, + retryable: retry.retryable, + ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), + ...(summary !== undefined + ? { message: summary.message, boundedProviderMessage: true as const } + : {}), }; } -function durableProviderErrorClass(classified: string, httpStatus: number | undefined): string { - // Structured context-overflow and capacity evidence can legitimately arrive - // behind a generic 4xx/5xx proxy response and remains stronger than the wrapper code. - if (classified === 'ContextLength' || classified === 'ProviderCapacity') return classified; +function normalizedProviderFailureClass( + classified: string, + httpStatus: number | undefined, +): ProviderFailureResult['errorClass'] { + // The semantic class already includes structured provider identifiers + // (capacity and context-overflow included) and therefore outranks the + // transport status used only as a fallback — structured capacity evidence + // can legitimately arrive behind a generic 4xx/5xx proxy response. + if (isProviderFailureClass(classified) && classified !== 'Other') return classified; if (httpStatus === 401 || httpStatus === 403) return 'Auth'; if (httpStatus === 402) return 'ProviderBilling'; if (httpStatus === 408) return 'Timeout'; @@ -416,7 +445,7 @@ function durableProviderErrorClass(classified: string, httpStatus: number | unde if (httpStatus !== undefined && httpStatus >= 500 && httpStatus <= 599) { return 'ProviderUnavailable'; } - return DURABLE_PROVIDER_ERROR_CLASSES.has(classified) ? classified : 'Other'; + return 'Other'; } function providerFailureDiagnosticFacts(error: unknown): ProviderErrorFacts | undefined { diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 9cfb0bfa9f..08e2b8934c 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -45,7 +45,7 @@ import { type ConnectionEffectError, type ConnectionTestEffectOutcome, } from './connection-effect-outcome.js'; -import { classifyError } from './provider-error-classification.js'; +import { providerFailureResult } from './provider-error-classification.js'; const CONNECTION_TEST_TIMEOUT_MS = 15_000; @@ -454,23 +454,14 @@ async function probeGoogle( async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise { const statusCode = r.status; - if (statusCode === 429) { - await r.cancel(); - return { - ok: false, - errorMessage: - 'OAuth 已登录,但当前账号或 provider 正在 rate limit。请稍后重试,或先切换到其它可用模型。', - statusCode, - errorClass: 'provider_unavailable', - latencyMs: Date.now() - t0, - }; - } const errorBody = await r.readText(CONNECTION_EFFECT_ERROR_BODY_MAX_BYTES); + const providerFailure = providerFailureResult({ statusCode, responseBody: errorBody }); return { ok: false, - errorMessage: `${statusCode} ${errorBody.slice(0, 200)}`, + ...(providerFailure.message !== undefined ? { errorMessage: providerFailure.message } : {}), statusCode, - errorClass: classifyHttpFailure(statusCode, errorBody), + errorClass: connectionTestErrorClassFromProviderClass(providerFailure.errorClass), + providerFailure, latencyMs: Date.now() - t0, }; } @@ -479,16 +470,6 @@ function stripTrailing(u: string): string { return u.replace(/\/+$/, ''); } -function classifyHttpFailure(statusCode: number, body: string): ConnectionTestResult['errorClass'] { - // The response body carries the provider's structured envelope; the numeric - // status is the fallback when the body is empty or unstructured. Both flow - // through the one shared provider-failure authority instead of a parallel - // status-only classifier. - const fromBody = classifyError(body); - const fromStatus = classifyError({ statusCode }); - return connectionTestErrorClassFromProviderClass(fromBody !== 'Other' ? fromBody : fromStatus); -} - function connectionTestErrorClassFromProviderClass(errorClass: string): ConnectionTestErrorClass { switch (errorClass) { case 'Auth': @@ -541,6 +522,13 @@ function classifyConnectionTestResult(result: ConnectionTestResult): ConnectionE // The body-aware classification computed at the probe boundary is // authoritative; the status-only fallback must not override it (a Kimi 403 // carrying a permission envelope is neutral, not a reauth). + if (result.providerFailure !== undefined) { + return { + kind: connectionTestErrorKind(result.errorClass), + ...(result.statusCode === undefined ? {} : { statusCode: result.statusCode }), + providerFailure: result.providerFailure, + }; + } if (result.errorClass !== undefined && result.errorClass !== 'unknown') { return { kind: connectionTestErrorKind(result.errorClass), From 5f4d278d6fd9464973b5fe020319d7a06ba11cb9 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 20:03:44 +0800 Subject: [PATCH 07/18] fix(runtime): preserve provider message provenance Generated-by: Maka --- .../provider-failure-presentation.test.ts | 5 +- .../cli/src/__tests__/pi-transcript.test.ts | 22 +++++++++ packages/cli/src/pi-transcript.ts | 5 +- .../core/src/__tests__/runtime-event.test.ts | 29 ++++++++++++ packages/core/src/runtime-event.ts | 2 + .../src/__tests__/model-adapter.test.ts | 5 ++ .../__tests__/provider-conformance.test.ts | 2 + .../provider-error-classification.test.ts | 9 ++++ packages/runtime/src/model-adapter.ts | 7 +-- .../src/provider-error-classification.ts | 46 +++++++++++++++++-- packages/runtime/src/test-connection.ts | 4 +- 11 files changed, 126 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index 0dd62da936..983fdcaef3 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -104,7 +104,10 @@ describe('provider failure presentation', () => { }, 'en'), 'RECHECK', ); - assert.notEqual(commandPaletteConnectionTestFailureMessage(result, 'en'), 'Authentication failed. Check the model key, subscription login, or credentials and try again.'); + assert.equal( + commandPaletteConnectionTestFailureMessage(result, 'en'), + 'The connection test failed. Try again later.', + ); }); test('renders only the Runtime-marked connection-test provider summary verbatim', () => { diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 6c0e2b755e..734c1f523b 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -237,6 +237,7 @@ describe('Maka Pi TUI transcript', () => { recoverable: false, code: 'permission_error', message, + boundedProviderMessage: true, }), ); @@ -250,6 +251,27 @@ describe('Maka Pi TUI transcript', () => { /usage limit/, ); }); + + test('hides an unmarked Runtime error behind the safe fallback', () => { + const state = createMakaPiTranscriptState(); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + code: 'permission_error', + message: 'unbounded provider response', + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: 'The task run failed. Try again later.', + }); + }); + test('keeps assistant text after a tool call visible after the tool block', () => { const state = createMakaPiTranscriptState(); appendUserPrompt(state, 'inspect the package'); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 87433d8e0e..3a93514cf0 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -741,7 +741,10 @@ export function applyMakaSessionEventToTranscript( state.entries.push({ kind: 'notice', level: 'error', - text: event.message, + text: + event.boundedProviderMessage === true + ? event.message + : 'The task run failed. Try again later.', }); break; diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 120d2eac3a..58880f1a6e 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -173,6 +173,35 @@ describe('continuation-start protocol', () => { }); describe('RuntimeEvent content variants', () => { + test('accepts only boolean provider-message bounds on error content', () => { + const decoded = decodeRuntimeEvent( + baseEvent({ + content: { + kind: 'error', + message: 'bounded provider response', + boundedProviderMessage: true, + }, + }), + ); + assert.equal( + decoded.content?.kind === 'error' ? decoded.content.boundedProviderMessage : undefined, + true, + ); + assert.throws( + () => + decodeRuntimeEvent( + baseEvent({ + content: { + kind: 'error', + message: 'untrusted provider response', + boundedProviderMessage: 'true', + } as never, + }), + ), + /RuntimeEvent schema/, + ); + }); + test('preserves sent inline references as message identity', () => { const inlineReferences = [ { kind: 'skill', value: '/skill:writer', label: 'Writer', start: 8 }, diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 6f1c5c9ace..193707bcc6 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -710,6 +710,8 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { hasExactShape(value, ERROR_CONTENT_SHAPE) && isOptionalString(value.code) && isOptionalString(value.reason) && + (value.boundedProviderMessage === undefined || + typeof value.boundedProviderMessage === 'boolean') && typeof value.message === 'string' && (value.details === undefined || isStringArray(value.details) || isRecord(value.details)) ); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index e85e1cee51..aa9889069c 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -641,6 +641,7 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(adapter.classifyError(error), 'Error'); assert.equal(event.reason, undefined); assert.equal(event.message, 'Network error'); + assert.equal(event.boundedProviderMessage, undefined); }); test('projects string provider errors through the same classification', () => { @@ -697,6 +698,10 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(event.code, 'permission_error'); assert.equal(event.message, `${observedMessage} (code=permission_error, status=403)`); assert.equal(event.boundedProviderMessage, true); + + const rawEvent = adapter.makeErrorEvent('turn-1', error); + assert.equal(rawEvent.message, `${observedMessage} (code=permission_error, status=403)`); + assert.equal(rawEvent.boundedProviderMessage, true); }); test('normalizes cache and reasoning usage variants in the adapter module', () => { diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index a53e13540f..d96c9c68ba 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -673,6 +673,8 @@ describe('models.dev provider conformance', () => { assert.equal(result.ok, false); assert.equal(result.statusCode, 401); assert.equal(result.errorClass, 'auth'); + assert.equal(result.errorMessage, undefined); + assert.equal(result.providerFailure?.boundedProviderMessage, undefined); }); test('OpenAI routes gpt-5* through the Responses wire and other models through Chat Completions by declaration', async () => { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 5df85a2410..924b45e97d 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -613,6 +613,15 @@ describe('Provider error classification', () => { }); }); + test('does not mark a metadata-only fallback as provider wording', () => { + assert.deepEqual(providerFailureResult({ statusCode: 403 }), { + errorClass: 'RequestRejected', + httpStatus: 403, + retryable: false, + message: 'Provider request failed (status=403)', + }); + }); + test('maps provider classes to stable user-safe presentations', () => { assert.deepEqual(errorPresentationFromClass('ProviderBilling'), { reason: 'provider_billing', diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 4d815aa66c..1cdc757d9f 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -431,7 +431,7 @@ export class ModelAdapter { } makeErrorEvent(turnId: string, err: unknown): ErrorEvent { - const failure = normalizeModelFailure(err); + const failure = normalizeProviderFailure(err); return { type: 'error', id: this.input.newId(), @@ -898,11 +898,12 @@ function normalizeModelFailure(error: unknown): ModelFailure { function normalizeProviderFailure(error: unknown): ModelFailure { if (isModelFailure(error)) return error; const result = providerFailureResult(error); - const presentation = errorPresentationFromClass(result.errorClass); + const errorClass = result.errorClass === 'Other' ? classifyError(error) : result.errorClass; + const presentation = errorPresentationFromClass(errorClass); // The bounded summary is display-safe provider wording; a generalized // presentation message is not. The marker must follow the message, not the // presence of a code (Error.code and provider codes both exist here). - const kind = modelFailureKind(result.errorClass); + const kind = modelFailureKind(errorClass); const boundedProviderMessage = kind === 'unknown' && result.boundedProviderMessage === true; return { type: 'model_failure', diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 192376b05d..57aa01c4b2 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -113,6 +113,10 @@ interface ProviderFailureSummary { code?: string; } +interface ProviderFailureSummaryEvidence extends ProviderFailureSummary { + boundedProviderMessage: boolean; +} + const PROVIDER_FAILURE_SUMMARY_MAX_BYTES = 2 * 1024; const PROVIDER_FAILURE_FIELD_MAX_BYTES = 256; @@ -335,12 +339,17 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined export function providerFailureSummary(error: unknown): ProviderFailureSummary | undefined { const facts = normalizeProviderError(error); if (!facts) return undefined; - return providerFailureSummaryFromFacts(facts); + const summary = providerFailureSummaryFromFacts(facts); + if (!summary) return undefined; + return { + message: summary.message, + ...(summary.code !== undefined ? { code: summary.code } : {}), + }; } function providerFailureSummaryFromFacts( facts: ProviderErrorFacts, -): ProviderFailureSummary | undefined { +): ProviderFailureSummaryEvidence | undefined { const sources = facts.summarySources; const message = firstProviderMessage(facts); const code = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); @@ -367,6 +376,7 @@ function providerFailureSummaryFromFacts( return { message: truncateUtf8(summary, PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'), ...(code || statusCode ? { code: code ?? statusCode } : {}), + boundedProviderMessage: message !== undefined && hasProviderMessageSource(facts), }; } @@ -404,7 +414,15 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { ? numericStatus : undefined; const classified = classifyProviderFacts(facts); - const errorClass = normalizedProviderFailureClass(classified, httpStatus); + const errorClass = normalizedProviderFailureClass( + classified === 'Auth' && + httpStatus !== undefined && + httpStatus !== 401 && + !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value)) + ? 'Other' + : classified, + httpStatus, + ); const providerCode = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); const providerRequestId = @@ -420,7 +438,12 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { retryable: retry.retryable, ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), ...(summary !== undefined - ? { message: summary.message, boundedProviderMessage: true as const } + ? { + message: summary.message, + ...(summary.boundedProviderMessage === true + ? { boundedProviderMessage: true as const } + : {}), + } : {}), }; } @@ -515,6 +538,21 @@ function firstProviderMessage(facts: ProviderErrorFacts): string | undefined { .find((value) => value !== undefined); } +function hasProviderMessageSource(facts: ProviderErrorFacts): boolean { + if (!(facts.target instanceof Error)) return true; + const targetRecord = objectRecord(facts.target); + return ( + facts.summarySources.records.some( + (source) => + source !== targetRecord && + boundedProviderMessage(safeField(source, 'message')) !== undefined, + ) || + facts.summarySources.stringErrors.some( + (candidate) => boundedProviderMessage(candidate) !== undefined, + ) + ); +} + function firstProviderField( sources: ProviderFailureSources, keys: readonly string[], diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 08e2b8934c..3543240d6a 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -458,7 +458,9 @@ async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise Date: Tue, 18 Aug 2026 20:20:26 +0800 Subject: [PATCH 08/18] test(runtime): align provider failure assertions Generated-by: Maka --- .../src/__tests__/model-adapter-onerror.test.ts | 1 - .../__tests__/scoped-fetch-transport.test.ts | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts index 0b2dd3de7c..91efb6579f 100644 --- a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts +++ b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts @@ -99,7 +99,6 @@ describe('ModelAdapter.startStream onError', () => { { type: 'model_failure', kind: 'rate_limit', - code: '429', message: 'Rate limit exceeded', retryable: true, retryAfterMs: 2500, diff --git a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts index 201705ab6a..0f3c1c337a 100644 --- a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts +++ b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts @@ -379,7 +379,16 @@ describe('connection effect network transport', () => { assert.equal(outcome.ok, false); if (outcome.ok) return; - assert.deepEqual(outcome.error, { kind: 'auth', statusCode: 401 }); + assert.deepEqual(outcome.error, { + kind: 'auth', + statusCode: 401, + providerFailure: { + errorClass: 'Auth', + httpStatus: 401, + retryable: false, + message: 'Provider request failed (status=401)', + }, + }); assert.equal(outcome.modelId, undefined); assert.equal(typeof outcome.latencyMs, 'number'); assert.deepEqual(Object.keys(outcome).sort(), ['error', 'latencyMs', 'ok']); @@ -388,7 +397,8 @@ describe('connection effect network transport', () => { const legacy = await testConnection(connection, 'provider-key', undefined, { fetch: transport.fetch, }); - assert.match(legacy.errorMessage ?? '', /raw-provider-auth-detail/); + assert.equal(legacy.errorMessage, undefined); + assert.equal(legacy.providerFailure?.boundedProviderMessage, undefined); } finally { await transport.close(); await closeServer(server); From c63cc922c89707642a2bf17ca3cf1cb09a62e0fe Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 21:31:21 +0800 Subject: [PATCH 09/18] fix(runtime): preserve structured cause semantics Generated-by: Maka --- .../provider-failure-presentation.test.ts | 15 ++++ .../src/renderer/model-connection-errors.ts | 6 +- .../cli/src/__tests__/pi-transcript.test.ts | 40 +++++++++ packages/cli/src/pi-transcript.ts | 45 +++++++++- .../provider-error-classification.test.ts | 23 +++++ .../src/provider-error-classification.ts | 84 ++++++++++++++----- 6 files changed, 184 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index 983fdcaef3..bd2bce52d6 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -60,6 +60,21 @@ describe('provider failure presentation', () => { assert.equal(sessionEventErrorMessage(event, 'en'), message); }); + test('preserves a bounded provider summary without a provider code', () => { + const event: Extract = { + type: 'error', + id: 'event-provider-summary', + turnId: 'turn-provider-summary', + ts: 1, + recoverable: false, + boundedProviderMessage: true, + message: 'Provider request failed safely.', + }; + + assert.equal(sessionEventErrorMessage(event), event.message); + assert.equal(sessionEventErrorMessage(event, 'en'), event.message); + }); + test('does not render a coded message verbatim without the bounded-provider marker', () => { const event: Extract = { type: 'error', diff --git a/apps/desktop/src/renderer/model-connection-errors.ts b/apps/desktop/src/renderer/model-connection-errors.ts index 79b607c18b..1be9fc1e6c 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -68,11 +68,7 @@ export function sessionEventErrorMessage( // status fragments in the presentation layer. The bounded marker is the only // proof that `message` is safe to render verbatim: a code alone can be a Node // transport code whose message is unbounded internal text. - if ( - event.boundedProviderMessage === true && - event.code !== undefined && - event.message.length > 0 - ) { + if (event.boundedProviderMessage === true && event.message.length > 0) { return event.message; } diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 734c1f523b..3dc711659f 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -252,6 +252,46 @@ describe('Maka Pi TUI transcript', () => { ); }); + test('renders a Runtime-owned reason before unmarked provider text', () => { + const state = createMakaPiTranscriptState(); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + reason: 'context_overflow', + message: 'unbounded provider response', + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: 'Context window exceeded', + }); + }); + + test('renders a marked provider summary without inventing a code requirement', () => { + const state = createMakaPiTranscriptState(); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + message: 'Provider request failed safely.', + boundedProviderMessage: true, + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: 'Provider request failed safely.', + }); + }); + test('hides an unmarked Runtime error behind the safe fallback', () => { const state = createMakaPiTranscriptState(); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 3a93514cf0..03cfaaf94a 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -741,10 +741,7 @@ export function applyMakaSessionEventToTranscript( state.entries.push({ kind: 'notice', level: 'error', - text: - event.boundedProviderMessage === true - ? event.message - : 'The task run failed. Try again later.', + text: transcriptErrorMessage(event), }); break; @@ -776,6 +773,46 @@ export function applyMakaSessionEventToTranscript( } } +function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { + switch (item.kind) { + case 'user': + return [ + { + kind: + item.message.origin?.kind === 'legacy_automation' + ? 'legacy_automation' + : item.message.origin?.kind === 'goal' + ? 'goal_continuation' + : 'user', + text: item.message.displayText ?? item.message.text, + }, + ]; + case 'assistant': { + const entries: MakaPiTranscriptEntry[] = []; + // Stored thinking happened before the reply text, so it resumes above it. + const thinking = item.message.thinking?.text; + if (thinking?.trim()) { + // Replay resets the expansion defaults to collapsed, so replayed + // entries start collapsed too. + entries.push({ + kind: 'thinking', + messageId: item.message.id, + text: thinking, + expanded: false, + }); + } + entries.push({ kind: 'assistant', messageId: item.message.id, text: item.message.text }); + return entries; + } + case 'tool': + return [toolActivityToTranscriptEntry(item.item)]; + case 'system_note': { + const entry = systemNoteToTranscriptEntry(item.message); + return entry ? [entry] : []; + } + } +} + function storedMessagesToTranscriptEntries( messages: readonly StoredMessage[], ): MakaPiTranscriptEntry[] { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 924b45e97d..047f31349e 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -86,6 +86,29 @@ describe('Provider error classification', () => { assert.equal(diagnostic.retryable, false); }); + test('ranks structured cause semantics above an outer HTTP status', () => { + const wrapped = (cause: unknown) => + Object.assign(new Error('transport wrapper rejected the request'), { + status: 403, + code: 'FETCH_FAILED', + cause, + }); + const cases: Array<[Record, string, string]> = [ + [{ error: { code: 'usage_limit_reached' } }, 'UsageLimit', 'usage_limit_reached'], + [{ error: { type: 'permission_denied' } }, 'ProviderPermission', 'permission_denied'], + [{ error: { code: 'context_length_exceeded' } }, 'ContextLength', 'context_length_exceeded'], + ]; + + for (const [cause, expectedClass, expectedCode] of cases) { + const result = providerFailureResult(wrapped(cause)); + assert.equal(result.errorClass, expectedClass); + assert.equal(result.httpStatus, 403); + assert.equal(result.providerCode, expectedCode); + assert.equal(result.retryable, false); + assert.deepEqual(providerRetryMetadata(wrapped(cause)), { retryable: false }); + } + }); + test('durable diagnostics distinguish the provider failure classes used by fail-open handling', () => { const cases: Array<[unknown, string]> = [ [Object.assign(new Error('bad request'), { statusCode: 400 }), 'RequestRejected'], diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 57aa01c4b2..4d57a3a196 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -93,6 +93,8 @@ interface ProviderErrorFacts { target: unknown; evidence: ProviderErrorEvidence; summarySources: ProviderFailureSources; + messageSources?: ProviderFailureSources; + boundedProviderMessageSource?: boolean; bareMessage?: string; responseHeaders?: Record; } @@ -165,7 +167,7 @@ function parseRetryAfterMs(headers: Record): number | null | und * response headers across the ModelAdapter boundary. */ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { - const facts = normalizeProviderError(error); + const facts = providerFailureDiagnosticFacts(error); if (!facts) return { retryable: false }; return providerRetryMetadataFromFacts(facts); } @@ -337,7 +339,7 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined * or serialized as diagnostic output wholesale. */ export function providerFailureSummary(error: unknown): ProviderFailureSummary | undefined { - const facts = normalizeProviderError(error); + const facts = providerFailureDiagnosticFacts(error); if (!facts) return undefined; const summary = providerFailureSummaryFromFacts(facts); if (!summary) return undefined; @@ -352,7 +354,7 @@ function providerFailureSummaryFromFacts( ): ProviderFailureSummaryEvidence | undefined { const sources = facts.summarySources; const message = firstProviderMessage(facts); - const code = firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); + const code = strongestProviderCode(facts); const statusCode = firstProviderField(sources, ['statusCode', 'status']); const requestId = firstProviderField(sources, ['requestId', 'request_id']) ?? @@ -423,8 +425,7 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { : classified, httpStatus, ); - const providerCode = - firstProviderField(sources, ['code']) ?? firstProviderField(sources, ['type']); + const providerCode = strongestProviderCode(facts); const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? boundedProviderField(facts.responseHeaders?.['x-request-id']); @@ -448,6 +449,24 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { }; } +function strongestProviderCode(facts: ProviderErrorFacts): string | undefined { + const semantic = facts.evidence.structuredCodes.find( + (value) => + PROVIDER_AUTH_CODES.has(value) || + PROVIDER_BILLING_CODES.has(value) || + PROVIDER_PERMISSION_CODES.has(value) || + PROVIDER_USAGE_LIMIT_CODES.has(value) || + PROVIDER_RATE_LIMIT_CODES.has(value) || + PROVIDER_UNAVAILABLE_CODES.has(value) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(value), + ); + return ( + boundedProviderField(semantic) ?? + firstProviderField(facts.summarySources, ['code']) ?? + firstProviderField(facts.summarySources, ['type']) + ); +} + function normalizedProviderFailureClass( classified: string, httpStatus: number | undefined, @@ -473,28 +492,50 @@ function normalizedProviderFailureClass( function providerFailureDiagnosticFacts(error: unknown): ProviderErrorFacts | undefined { let current = providerErrorTarget(error); - let fallback: ProviderErrorFacts | undefined; - let structuredFallback: ProviderErrorFacts | undefined; - let codedFallback: ProviderErrorFacts | undefined; + const chain: ProviderErrorFacts[] = []; const seen = new Set(); for (let depth = 0; depth < 4 && current !== undefined && !seen.has(current); depth += 1) { seen.add(current); const facts = normalizeProviderError(current); - fallback ??= facts; - // HTTP status is the strongest durable evidence: an SDK wrapper can carry - // its own transport `code` (for example `FETCH_FAILED`) while the real - // provider status sits on the wrapped cause. Prefer the status-bearing - // fact, and only fall back to wrapper-level structured codes when no fact - // in the chain carries one. - if (facts?.evidence.statusCode) return facts; - structuredFallback ??= facts && facts.evidence.structuredCodes.length > 0 ? facts : undefined; - if (facts?.evidence.code) codedFallback ??= facts; + if (facts) chain.push(facts); current = current && typeof current === 'object' ? safeField(current as Record, 'cause') : undefined; } - return structuredFallback ?? codedFallback ?? fallback; + if (chain.length === 0) return undefined; + + // Provider semantics can sit below an SDK/transport wrapper that also owns + // the HTTP status. Aggregate the bounded cause chain instead of letting the + // first status-bearing object discard stronger structured codes. Provider + // sources are ordered inner-first for message/code projection; transport + // status and retry hints remain available as fallback evidence. + const providerFirst = [...chain].reverse(); + const messageFacts = providerFirst.filter((facts) => hasProviderMessageSource(facts)); + const responseHeaders = Object.assign({}, ...chain.map((facts) => facts.responseHeaders ?? {})) as + | Record + | undefined; + const bareMessage = messageFacts.find((facts) => facts.bareMessage)?.bareMessage; + return { + target: chain[0]!.target, + evidence: { + text: chain.map((facts) => facts.evidence.text).join(' '), + statusCode: chain.find((facts) => facts.evidence.statusCode)?.evidence.statusCode ?? '', + code: chain.find((facts) => facts.evidence.code)?.evidence.code ?? '', + structuredCodes: [...new Set(chain.flatMap((facts) => facts.evidence.structuredCodes))], + }, + summarySources: { + records: providerFirst.flatMap((facts) => facts.summarySources.records), + stringErrors: providerFirst.flatMap((facts) => facts.summarySources.stringErrors), + }, + messageSources: { + records: messageFacts.flatMap((facts) => facts.summarySources.records), + stringErrors: messageFacts.flatMap((facts) => facts.summarySources.stringErrors), + }, + boundedProviderMessageSource: messageFacts.length > 0, + ...(bareMessage ? { bareMessage } : {}), + ...(responseHeaders && Object.keys(responseHeaders).length > 0 ? { responseHeaders } : {}), + }; } interface ProviderFailureSources { @@ -528,7 +569,7 @@ function providerRecord(value: unknown): Record | undefined { function firstProviderMessage(facts: ProviderErrorFacts): string | undefined { if (facts.bareMessage !== undefined) return boundedProviderMessage(facts.bareMessage); - const sources = facts.summarySources; + const sources = facts.messageSources ?? facts.summarySources; const candidates = [ ...sources.records.map((source) => safeField(source, 'message')), ...sources.stringErrors, @@ -539,6 +580,9 @@ function firstProviderMessage(facts: ProviderErrorFacts): string | undefined { } function hasProviderMessageSource(facts: ProviderErrorFacts): boolean { + if (facts.boundedProviderMessageSource !== undefined) { + return facts.boundedProviderMessageSource; + } if (!(facts.target instanceof Error)) return true; const targetRecord = objectRecord(facts.target); return ( @@ -732,7 +776,7 @@ export function isContextOverflowErrorText(text: string): boolean { */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; - const facts = normalizeProviderError(error); + const facts = providerFailureDiagnosticFacts(error); return facts ? classifyProviderFacts(facts) : 'Other'; } From 155e2a1702b81041d875eaebac018b8d6033ed51 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 23:53:11 +0800 Subject: [PATCH 10/18] fix(cli): localize runtime error notices Generated-by: Maka --- .../cli/src/__tests__/pi-transcript.test.ts | 18 +++- packages/cli/src/pi-transcript.ts | 82 ++++++++++++++++--- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 3dc711659f..90d4343a12 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -245,6 +245,7 @@ describe('Maka Pi TUI transcript', () => { kind: 'notice', level: 'error', text: message, + runtimeError: {}, }); assert.match( renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'), @@ -268,8 +269,14 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(state.entries.at(-1), { kind: 'notice', level: 'error', - text: 'Context window exceeded', + text: '', + runtimeError: { reason: 'context_overflow' }, }); + const chinese = renderMakaPiTranscript(state, { ...meta(), uiLocale: 'zh' }, 100) + .map(stripAnsi) + .join('\n'); + assert.match(chinese, /上下文窗口已超出限制/); + assert.doesNotMatch(chinese, /Context window exceeded/); }); test('renders a marked provider summary without inventing a code requirement', () => { @@ -289,6 +296,7 @@ describe('Maka Pi TUI transcript', () => { kind: 'notice', level: 'error', text: 'Provider request failed safely.', + runtimeError: {}, }); }); @@ -308,8 +316,14 @@ describe('Maka Pi TUI transcript', () => { assert.deepEqual(state.entries.at(-1), { kind: 'notice', level: 'error', - text: 'The task run failed. Try again later.', + text: '', + runtimeError: {}, }); + const chinese = renderMakaPiTranscript(state, { ...meta(), uiLocale: 'zh' }, 100) + .map(stripAnsi) + .join('\n'); + assert.match(chinese, /任务运行失败,请稍后重试/); + assert.doesNotMatch(chinese, /The task run failed/); }); test('keeps assistant text after a tool call visible after the tool block', () => { diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 03cfaaf94a..df8e0630a8 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -177,7 +177,14 @@ export type MakaPiTranscriptEntry = /** An internal shell-run poll retained for correlation but not displayed. */ suppressed?: boolean; } - | { kind: 'notice'; level: 'info' | 'error'; text: string }; + | { + kind: 'notice'; + level: 'info' | 'error'; + /** Already-safe display text. Runtime errors leave this empty unless the Host bounded it. */ + text: string; + /** Stable Runtime reason retained until the locale-aware render boundary. */ + runtimeError?: { reason?: string }; + }; export interface MakaPiTranscriptMetadata { title: string; @@ -741,7 +748,9 @@ export function applyMakaSessionEventToTranscript( state.entries.push({ kind: 'notice', level: 'error', - text: transcriptErrorMessage(event), + text: + event.boundedProviderMessage === true && event.message.length > 0 ? event.message : '', + runtimeError: event.reason ? { reason: event.reason.toLowerCase() } : {}, }); break; @@ -1161,8 +1170,7 @@ export function renderMakaPiTranscript( lines.length < viewportTop && (entryHeight === 0 || lines.length + entryHeight <= viewportTop); lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen)); - previousVisibleEntry = entry; - } + previousVisibleEntry = entry; } state.renderGeometry.entryFirstLine = entryFirstLine; if (state.pendingInteraction?.type === 'sandbox_boundary_request') { @@ -1254,6 +1262,7 @@ function renderTranscriptEntryMemoized( entry: MakaPiTranscriptEntry, width: number, offScreen: boolean, + locale: UiLocale, ): string[] { // Off-screen entries live in terminal scrollback, which is immutable: any // change to their rendered lines forces pi-tui's differential renderer into a @@ -1266,15 +1275,19 @@ function renderTranscriptEntryMemoized( const cached = transcriptEntryRenderCache.get(entry); if (cached && cached.width === width) return cached.lines; } - const signature = transcriptEntrySignature(entry, width); + const signature = transcriptEntrySignature(entry, width, locale); const cached = transcriptEntryRenderCache.get(entry); if (cached && cached.signature === signature) return cached.lines; - const lines = renderTranscriptEntryBlock(entry, width); + const lines = renderTranscriptEntryBlock(entry, width, locale); transcriptEntryRenderCache.set(entry, { signature, lines, width }); return lines; } -function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number): string[] { +function renderTranscriptEntryBlock( + entry: MakaPiTranscriptEntry, + width: number, + locale: UiLocale, +): string[] { // Keep the conversation stream inside a one-cell gutter. The editor owns // the full terminal width, so this makes the two surfaces align without // changing any of the individual block renderers' internal prefixes. @@ -1294,7 +1307,11 @@ function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number) case 'tool': return renderToolBlock(entry, contentWidth, entry.expanded); case 'notice': - return renderNotice(entry, contentWidth); + return renderNotice( + entry, + contentWidth, + entry.runtimeError ? transcriptErrorMessage(entry, locale) : entry.text, + ); } })(); @@ -1313,7 +1330,11 @@ function isBlankTranscriptLine(line: string): boolean { return line.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').trim().length === 0; } -function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): string { +function transcriptEntrySignature( + entry: MakaPiTranscriptEntry, + width: number, + locale: UiLocale, +): string { switch (entry.kind) { // User text is immutable, so length is a safe change key. case 'user': @@ -1332,7 +1353,9 @@ function transcriptEntrySignature(entry: MakaPiTranscriptEntry, width: number): // then serve stale reasoning from the cache. Key on the full text. return `thinking|${width}|${entry.expanded ? 1 : 0}|${entry.text}`; case 'notice': - return `notice|${width}|${entry.level}|${entry.text.length}`; + return `notice|${width}|${entry.level}|${ + entry.runtimeError ? transcriptErrorMessage(entry, locale) : entry.text + }`; case 'tool': // A tool entry mutates in place as it runs: its derived presentation and // duration change, progress/output deltas append, and resultVersion @@ -1777,9 +1800,44 @@ function renderAssistantBlock(text: string, width: number): string[] { .map((line) => fitLine(line, width)); } -function renderNotice(entry: MakaPiNoticeEntry, width: number): string[] { +function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): string { + const copy = + locale === 'zh' + ? { + context_overflow: '上下文窗口已超出限制', + timeout: '请求超时', + auth: '鉴权失败', + provider_billing: '模型服务计费受限', + provider_permission: '模型服务拒绝访问', + provider_unavailable: '模型服务返回错误', + rate_limit: '触发模型速率限制', + usage_limit: '模型使用额度已用完', + network: '网络错误', + fallback: '任务运行失败,请稍后重试。', + } + : { + context_overflow: 'Context window exceeded', + timeout: 'Request timed out', + auth: 'Authentication failed', + provider_billing: 'Provider billing required', + provider_permission: 'Provider access denied', + provider_unavailable: 'Provider returned an error', + rate_limit: 'Rate limit exceeded', + usage_limit: 'Usage limit reached', + network: 'Network error', + fallback: 'The task run failed. Try again later.', + }; + const reason = entry.runtimeError?.reason; + if (reason && reason in copy && reason !== 'fallback') { + return copy[reason as Exclude]; + } + if (entry.text.length > 0) return entry.text; + return copy.fallback; +} + +function renderNotice(entry: MakaPiNoticeEntry, width: number, text: string): string[] { const label = entry.level === 'error' ? ansi.red('Error') : ansi.dim('Note'); - return renderIndented(`${label}: ${entry.text}`, width, 0).map((line) => fitLine(line, width)); + return renderIndented(`${label}: ${text}`, width, 0).map((line) => fitLine(line, width)); } // Shown on a fresh, empty session. Greets with the branded maka wordmark and a From 7d31607ebb7e3b696e32e21ddceb7b1c680e8925 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 08:46:21 +0800 Subject: [PATCH 11/18] fix(runtime): preserve structured provider failures Generated-by: Maka --- .../provider-failure-presentation.test.ts | 20 +++++++++++++++++++ apps/desktop/src/renderer/app-shell-copy.ts | 3 +++ .../renderer/session-error-presentation.ts | 17 ++++++++++++++++ .../settings/provider-panel-shared.ts | 3 +++ .../__tests__/handshake-compatibility.test.ts | 4 ++-- .../__tests__/provider-conformance.test.ts | 1 + .../provider-error-classification.test.ts | 6 +++++- .../__tests__/scoped-fetch-transport.test.ts | 1 - .../src/provider-error-classification.ts | 7 +++---- 9 files changed, 54 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index bd2bce52d6..f814e7921a 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -147,4 +147,24 @@ describe('provider failure presentation', () => { ); assert.equal(commandPaletteConnectionTestFailureMessage(result, 'en'), message); }); + + test('preserves structured account meaning without provider message text', () => { + const result = { + ok: false, + statusCode: 429, + errorClass: 'provider_unavailable' as const, + providerFailure: { + errorClass: 'UsageLimit' as const, + httpStatus: 429, + providerCode: 'usage_limit_reached', + retryable: false, + }, + }; + + assert.equal( + connectionTestFailureMessage(result, { auth: 'AUTH', recheck: 'RECHECK' }, 'en'), + 'Model usage limit reached', + ); + assert.equal(commandPaletteConnectionTestFailureMessage(result, 'en'), 'Model usage limit reached'); + }); }); diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 9861dc415c..974032123d 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -22,6 +22,7 @@ import type { TextFileImportPreflightFailureReason } from '@maka/core/text-file- import type { UiLocale } from '@maka/core/ui-locale'; import { generalizedErrorMessage, generalizedErrorMessageChinese } from '@maka/core/redaction'; import { getShellCopy } from './locales/shell-copy.js'; +import { describeProviderAccountFailure } from './session-error-presentation.js'; const SESSION_READ_MESSAGES_ERROR_MARKER = 'MAKA_SESSION_READ_MESSAGES_ERROR:'; @@ -67,6 +68,8 @@ export function commandPaletteConnectionTestFailureMessage(result: ConnectionTes } function commandPaletteConnectionTestFailureFallback(result: ConnectionTestResult, locale: UiLocale): string { + const accountFailure = describeProviderAccountFailure(result.providerFailure?.errorClass, locale); + if (accountFailure) return accountFailure; const copy = getShellCopy(locale).commandActions.connectionFailures; switch (result.providerFailure?.errorClass) { case 'Auth': diff --git a/apps/desktop/src/renderer/session-error-presentation.ts b/apps/desktop/src/renderer/session-error-presentation.ts index ecc2500f21..8e4306738a 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -52,3 +52,20 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U return undefined; } } + +/** Shared safe copy for structured provider account failures without displayable provider text. */ +export function describeProviderAccountFailure( + errorClass: string | undefined, + locale: UiLocale = 'zh', +): string | undefined { + switch (errorClass) { + case 'ProviderBilling': + return describeSessionErrorReason('provider_billing', locale); + case 'ProviderPermission': + return describeSessionErrorReason('provider_permission', locale); + case 'UsageLimit': + return describeSessionErrorReason('usage_limit', locale); + default: + return undefined; + } +} diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts index 35b4c7bf47..d68a56a4dd 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -32,6 +32,7 @@ import { import { type UiLocale } from '@maka/core/ui-locale'; import { getProviderSettingsCopy } from '../locales/settings-provider-copy.js'; import { cleanErrorMessage } from '../model-connection-errors.js'; +import { describeProviderAccountFailure } from '../session-error-presentation.js'; import type { DesktopConnectionSnapshot } from '../../shared/desktop-connection-snapshot.js'; export interface ConnectionsBridge { @@ -91,6 +92,8 @@ export function connectionTestFailureFallback( copy: ConnectionTestTroubleshootingCopy, locale: UiLocale = 'zh', ): string { + const accountFailure = describeProviderAccountFailure(result.providerFailure?.errorClass, locale); + if (accountFailure) return accountFailure; const shared = getProviderSettingsCopy(locale).shared; switch (result.providerFailure?.errorClass) { case 'Auth': diff --git a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts index 69e9dddd9d..f07a7014ec 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -108,7 +108,7 @@ test('receives structured incompatibility guidance from the released v0.1.11 Hos ); }); -test('rejects an epoch-39 Host before any domain command', async () => { +test('rejects the previous compatibility epoch before any domain command', async () => { let admittedRequest: RequestFrame | undefined; await withForgedHandshakePeer( async (transport, hostEpoch, rootId) => { @@ -120,7 +120,7 @@ test('rejects an epoch-39 Host before any domain command', async () => { hostEpoch, connectionId: 'forged-epoch-connection', selectedProtocol: RUNTIME_HOST_PROTOCOL_VERSION, - compatibilityEpoch: 39, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, compositionId: 'maka.interactive', compositionRevision: '1', state: 'ready', diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index d96c9c68ba..a802d1f0fb 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -674,6 +674,7 @@ describe('models.dev provider conformance', () => { assert.equal(result.statusCode, 401); assert.equal(result.errorClass, 'auth'); assert.equal(result.errorMessage, undefined); + assert.equal(result.providerFailure?.message, undefined); assert.equal(result.providerFailure?.boundedProviderMessage, undefined); }); diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 047f31349e..de49ef6fa0 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -558,6 +558,11 @@ describe('Provider error classification', () => { assert.equal(classifyError(planUsageLimit), 'UsageLimit'); assert.deepEqual(providerRetryMetadata(planUsageLimit), { retryable: false }); + const abortedUsageLimit = providerError(429, 'The request was aborted at the account limit', { + type: 'usage_limit_reached', + }); + assert.equal(classifyError(abortedUsageLimit), 'UsageLimit'); + const permission = providerError(403, 'This key cannot access the requested model', { type: 'permission_denied', }); @@ -641,7 +646,6 @@ describe('Provider error classification', () => { errorClass: 'RequestRejected', httpStatus: 403, retryable: false, - message: 'Provider request failed (status=403)', }); }); diff --git a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts index 0f3c1c337a..b623baf59a 100644 --- a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts +++ b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts @@ -386,7 +386,6 @@ describe('connection effect network transport', () => { errorClass: 'Auth', httpStatus: 401, retryable: false, - message: 'Provider request failed (status=401)', }, }); assert.equal(outcome.modelId, undefined); diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 4d57a3a196..7e27977842 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -438,12 +438,10 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { ...(providerRequestId !== undefined ? { providerRequestId } : {}), retryable: retry.retryable, ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), - ...(summary !== undefined + ...(summary?.boundedProviderMessage === true ? { message: summary.message, - ...(summary.boundedProviderMessage === true - ? { boundedProviderMessage: true as const } - : {}), + boundedProviderMessage: true as const, } : {}), }; @@ -807,6 +805,7 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) return 'RateLimit'; if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) return 'ProviderUnavailable'; + if (text.includes('abort')) return 'Abort'; if (statusCode === '402' || code === '402') return 'ProviderBilling'; if (statusCode === '429' || code === '429') return 'RateLimit'; if (statusCode === '401' || code === '401') return 'Auth'; From 0ee1022afa7205a41270f1ad87f69f001af5f52e Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 18:31:22 +0800 Subject: [PATCH 12/18] fix(runtime): prioritize structured context overflow Generated-by: Maka --- .../provider-error-classification.test.ts | 17 ++++++++++++ .../src/provider-error-classification.ts | 26 +++++++++---------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index de49ef6fa0..4eafe5819e 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -109,6 +109,23 @@ describe('Provider error classification', () => { } }); + test('ranks structured context overflow above numeric and text fallbacks', () => { + const cases = [ + Object.assign(new Error('provider rejected the request'), { + statusCode: 429, + data: { error: { code: 'context_length_exceeded' } }, + }), + Object.assign(new Error('request aborted because the prompt is too long'), { + data: { error: { code: 'context_length_exceeded' } }, + }), + ]; + + for (const error of cases) { + assert.equal(classifyError(error), 'ContextLength'); + assert.deepEqual(providerRetryMetadata(error), { retryable: false }); + } + }); + test('durable diagnostics distinguish the provider failure classes used by fail-open handling', () => { const cases: Array<[unknown, string]> = [ [Object.assign(new Error('bad request'), { statusCode: 400 }), 'RequestRejected'], diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 7e27977842..81f03871e2 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -761,16 +761,16 @@ export function isContextOverflowErrorText(text: string): boolean { /** * Classifies a provider error by DESCENDING evidence strength over the * normalized evidence (Error, string, or plain stream-error-part object): - * abort → structured account state → 402 → 429 → 401 (numeric fields, - * never substrings) → the provider's structured capacity and overflow codes → - * bare 413 (HTTP: request entity too large — itself input-side evidence, - * Cerebras sends it with no body) → numeric HTTP fallbacks → vetoable - * free-text relations → generic 5xx → weak word heuristics. Exact provider - * evidence outranks generic HTTP/text evidence because gateways can wrap a - * provider failure in a misleading status or message; specific overflow - * evidence outranks a generic 5xx because proxies (LiteLLM) wrap provider - * overflows in 503s; the weak heuristics rank last so "generate" can never - * become a rate limit. + * abort wrapper → structured account state (capacity, overflow, auth, + * billing, permission, usage/rate limits) → numeric HTTP fallbacks (402, 429, + * 401; numeric fields, never substrings) → bare 413 + * (HTTP: request entity too large — itself input-side evidence, Cerebras sends + * it with no body) → vetoable free-text relations → generic 5xx → weak word + * heuristics. Exact provider evidence outranks generic HTTP/text evidence + * because gateways can wrap a provider failure in a misleading status or + * message; specific overflow evidence outranks a generic 5xx because proxies + * (LiteLLM) wrap provider overflows in 503s; the weak heuristics rank last so + * "generate" can never become a rate limit. */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -805,6 +805,9 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) return 'RateLimit'; if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) return 'ProviderUnavailable'; + // The provider's structured context code is unconditional input-overflow + // evidence. It outranks outer transport status and message fallbacks. + if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; if (text.includes('abort')) return 'Abort'; if (statusCode === '402' || code === '402') return 'ProviderBilling'; if (statusCode === '429' || code === '429') return 'RateLimit'; @@ -812,9 +815,6 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { // A bare 403 is intentionally unknown: providers use it for valid-key // permission failures, guardrails, subscription limits, and occasionally // authentication. The provider's bounded diagnostic remains available. - // Structured provider evidence: the parsed error JSON's code/type is the - // only unconditional signal for a context overflow. - if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; if (statusCode === '413' || code === '413') return 'ContextLength'; // Free-text overflow relations on the composite text, veto-first inside. if (isContextOverflowErrorText(text)) return 'ContextLength'; From 150036699eaa548ea6b0322a471a08ba278fb30b Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 20:07:16 +0800 Subject: [PATCH 13/18] fix(runtime): keep context overflow out of generic retry Generated-by: Codex --- .../src/__tests__/provider-error-classification.test.ts | 4 ++++ packages/runtime/src/provider-error-classification.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 4eafe5819e..a28d71ae7a 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -118,6 +118,10 @@ describe('Provider error classification', () => { Object.assign(new Error('request aborted because the prompt is too long'), { data: { error: { code: 'context_length_exceeded' } }, }), + Object.assign(new Error('proxy service unavailable'), { + statusCode: 503, + data: { error: { code: 'context_length_exceeded' } }, + }), ]; for (const error of cases) { diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 81f03871e2..81d90931d3 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -185,7 +185,8 @@ function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetr errorClass === 'Auth' || errorClass === 'ProviderBilling' || errorClass === 'ProviderPermission' || - errorClass === 'UsageLimit' + errorClass === 'UsageLimit' || + errorClass === 'ContextLength' ) { return { retryable: false }; } From f6663f775157612fb3546003c1f1710f1b0d2ef9 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 21:21:42 +0800 Subject: [PATCH 14/18] fix(runtime-host): advance provider failure epoch Generated-by: Codex --- packages/cli/src/pi-transcript.ts | 4 +++- .../runtime-host/src/__tests__/protocol.test.ts | 7 +++++++ packages/runtime-host/src/protocol/index.ts | 13 +++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index df8e0630a8..49cf9c022b 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1169,7 +1169,9 @@ export function renderMakaPiTranscript( const fullyOffScreen = lines.length < viewportTop && (entryHeight === 0 || lines.length + entryHeight <= viewportTop); - lines.push(...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen)); + lines.push( + ...renderTranscriptEntryMemoized(entry, safeWidth, fullyOffScreen, metadata.uiLocale ?? 'en'), + ); previousVisibleEntry = entry; } state.renderGeometry.entryFirstLine = entryFirstLine; diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index d2cca28c39..9f37d28d2d 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -162,6 +162,13 @@ describe('Runtime Host bootstrap protocol', () => { // Epoch 38 peers reject the additional tool descriptor field and progress // frame, so the capability must be negotiated at a newer epoch. assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 38); + + test('publishes a new compatibility epoch for structured provider failures', () => { + // Epoch 40 carries the structured provider failure code and bounded + // provider message on Turn snapshots; mixed-version peers must fail the + // handshake instead of failing on the first classified failure. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 39); + }); }); test('adds credential rotation without changing existing credential inputs', () => { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index eb63d7d0ab..e484cda182 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,11 +91,18 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. +<<<<<<< HEAD export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 44 as const; // 44: Session continuity and inspection stop carrying the retired Session // last-used timestamp. Older peers reject those strict projection shapes. // 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. // Older peers reject that added closed-union field. +======= +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 43 as const; +// 43: Durable provider failures may classify as `ProviderPermission`; older +// peers reject that strict error-class value, so mixed versions must fail +// handshake before the class can surface. +>>>>>>> 1e6c4a139 (fix(runtime-host): advance provider failure epoch) // 42: Turn provider retry progress adds `provider_capacity`. Older peers reject // that strict retry-reason enum value, so mixed versions must fail handshake. // 41: Context compaction returns a typed terminal outcome on both Turn @@ -128,8 +135,14 @@ export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 44 as const; // and tears the connection down, so the pair must be refused up front. // 28: Relay model profiles carry the Fast service-tier declaration. Older // peers cannot safely preserve that Runtime Policy field. +// 40: Turn snapshots carry structured provider failure codes and bounded +// provider messages. Older peers cannot safely preserve those fields. // 27: Runtime Policy carries the Host-owned shell preference used by tool, // PTY, and prompt composition. Older peers cannot safely preserve that field. +// 28: Relay model profiles carry the Fast service-tier declaration. Older +// peers cannot safely preserve that Runtime Policy field. +// 29: Turn snapshots carry structured provider failure codes and bounded +// provider messages. Older peers cannot safely preserve those fields. // Transcript pages amortize storage and network round trips with a 512 KiB raw // payload. Base64 expansion plus the bounded fragment envelope must still fit in // one transport message; narrower domains retain their own encoded limits. From 516fce863b873434582ef4b1dfcc2f7df5cc8a12 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 20:56:38 +0800 Subject: [PATCH 15/18] fix(runtime): tighten provider failure provenance and taxonomy inputs - Require positive provider provenance before certifying a chain link's message as a bounded provider message: a plain-object cause whose only message is its own .message is internal text, never provider wording. - Rank numeric status above free text in classification: a 429/500 whose body or JSON key names mention "aborted" keeps RateLimit / ProviderUnavailable, preserving retry-after handling. - Treat a text-derived Abort as non-retryable, matching the RetryError early return so the same class no longer carries opposite retry semantics. - Select the projected message and its paired code/status from the same cause-chain link instead of combining an inner link's message with an outer link's provider code. - Gate ErrorEvent.code through a closed vocabulary (semantic provider codes, numeric HTTP statuses, Maka-owned sentinels) so a provider's free-form token can never steer the Host's terminal-state taxonomy or the Desktop label/recovery matching. - Use Object.hasOwn for the provider-influenced notice-copy lookup. - Replace the epoch ladder assertion with handshake compatibility coverage and test both older- and newer-peer epoch rejection. Generated with AI assistance Generated-by: Maka --- packages/cli/src/pi-transcript.ts | 4 +- .../src/__tests__/host-kernel.test.ts | 47 ++++++++++ .../src/__tests__/protocol.test.ts | 12 +-- .../src/__tests__/model-adapter.test.ts | 33 ++++++- .../provider-error-classification.test.ts | 62 +++++++++++++ packages/runtime/src/model-adapter.ts | 7 +- .../src/provider-error-classification.ts | 86 +++++++++++++++++-- 7 files changed, 233 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 49cf9c022b..cd92fc0343 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1830,7 +1830,9 @@ function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): str fallback: 'The task run failed. Try again later.', }; const reason = entry.runtimeError?.reason; - if (reason && reason in copy && reason !== 'fallback') { + // `in` reaches Object.prototype: a provider-influenced reason like + // 'constructor' would interpolate a function into the notice (#2521). + if (reason && Object.hasOwn(copy, reason) && reason !== 'fallback') { return copy[reason as Exclude]; } if (entry.text.length > 0) return entry.text; diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 8ac0ac765b..86125f9a5c 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -2203,6 +2203,53 @@ describe('non-serving Runtime Host kernel', () => { }); }); + test('rejects a handshake whose compatibility epoch does not match', async () => { + // Both directions: an older peer and a NEWER peer are both refused. + for (const compatibilityEpoch of [ + RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, + RUNTIME_HOST_COMPATIBILITY_EPOCH + 1, + ]) { + await withHostPaths(async (paths) => { + const candidate = await startTestRuntimeHostCandidate(paths, { + rootPath: paths.root, + idleGraceMs: 10_000, + }); + assert.equal(candidate.kind, 'winner'); + if (candidate.kind !== 'winner') return; + + const transport = new FramedTransport(await openSocket(candidate.host.endpoint)); + try { + await writeClientFrame(transport, { + kind: 'hello', + clientInstanceId: 'epoch-mismatch-client', + protocolMin: CURRENT_PROTOCOL.min, + protocolMax: CURRENT_PROTOCOL.max, + compatibilityEpoch, + compositionId: 'maka.interactive', + }); + const response = decodeHostFrame(await transport.read(2_000)); + assert.ok('kind' in response && response.kind === 'incompatible'); + if (!('kind' in response) || response.kind !== 'incompatible') return; + assert.equal(response.compatibilityEpoch, RUNTIME_HOST_COMPATIBILITY_EPOCH); + assert.equal(response.hostEpoch, candidate.host.hostEpoch); + await transport.closed; + await assert.rejects( + () => + writeClientFrame(transport, { + requestId: 'post-epoch-mismatch-status', + operation: 'host.status', + input: {}, + }), + (error: unknown) => + error instanceof RuntimeHostTransportError && error.code === 'closed', + ); + } finally { + transport.abort(); + } + }); + } + }); + test('accepts Client hellos with and without the legacy surface identity', async () => { await withHostPaths(async (paths) => { const candidate = await startTestRuntimeHostCandidate(paths, { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 9f37d28d2d..9811ddd50e 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -163,12 +163,12 @@ describe('Runtime Host bootstrap protocol', () => { // frame, so the capability must be negotiated at a newer epoch. assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 38); - test('publishes a new compatibility epoch for structured provider failures', () => { - // Epoch 40 carries the structured provider failure code and bounded - // provider message on Turn snapshots; mixed-version peers must fail the - // handshake instead of failing on the first classified failure. - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 39); - }); + test('publishes a new compatibility epoch for structured provider failures', () => { + // Epoch 40 carries the structured provider failure code and bounded + // provider message on Turn snapshots; mixed-version peers must fail the + // handshake instead of failing on the first classified failure. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 39); + }); }); test('adds credential rotation without changing existing credential inputs', () => { diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index aa9889069c..50f026d75c 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -248,6 +248,29 @@ describe('ModelAdapter stream and error normalization', () => { }); }); + test('keeps a free-form provider code out of the failure taxonomy input (#2521)', () => { + const adapter = newAdapter(); + type Chunk = Parameters[0]; + const error = Object.assign(new Error('Invalid request'), { + name: 'AI_APICallError', + statusCode: 400, + data: { error: { code: 'tool_choice_invalid', message: 'tool_choice is not supported' } }, + }); + + const events: ModelStreamEvent[] = adapter.translateChunk({ type: 'error', error } as Chunk); + + const errorEvent = events.find( + (event): event is Extract => event.kind === 'error', + ); + assert.ok(errorEvent); + // 'tool_choice_invalid' contains 'tool' but must not steer the Host's + // terminal-state taxonomy or the Desktop label/recovery substring + // matching that reads it. + assert.equal(errorEvent.failure.code, undefined); + const shaped = adapter.makeErrorEvent('turn-1', errorEvent.failure); + assert.equal(shaped.code, undefined); + }); + test('preserves an explicit empty reasoning delta without inventing one for absent text', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; @@ -664,7 +687,10 @@ describe('ModelAdapter stream and error normalization', () => { const event = adapter.makeErrorEvent('turn-1', failure); assert.equal(event.reason, undefined); - assert.equal(event.code, 'provider_error'); + // A free-form provider code stays in the bounded summary text but no + // longer enters `code`: that field feeds the Host's terminal-state + // taxonomy, which only a closed vocabulary may steer (#2521). + assert.equal(event.code, undefined); assert.match(event.message, /^provider exploded api_key=\[redacted\]/); assert.match(event.message, /… \(code=provider_error, requestId=req-123\)$/); assert.equal(Buffer.byteLength(event.message, 'utf8') <= 2 * 1024, true); @@ -689,13 +715,14 @@ describe('ModelAdapter stream and error normalization', () => { type: 'model_failure', kind: 'unknown', retryable: false, - code: 'permission_error', message: `${observedMessage} (code=permission_error, status=403)`, boundedProviderMessage: true, }); const event = adapter.makeErrorEvent('turn-1', failure); assert.equal(event.reason, undefined); - assert.equal(event.code, 'permission_error'); + // 'permission_error' is not closed-vocabulary: it remains in the summary + // text but must not become a taxonomy input (#2521). + assert.equal(event.code, undefined); assert.equal(event.message, `${observedMessage} (code=permission_error, status=403)`); assert.equal(event.boundedProviderMessage, true); diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index a28d71ae7a..73147e1eaa 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -30,6 +30,7 @@ import { errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, + taxonomySafeProviderCode, } from '../provider-error-classification.js'; describe('Provider error classification', () => { @@ -670,6 +671,67 @@ describe('Provider error classification', () => { }); }); + test('requires positive provider provenance before certifying a cause message (#2521)', () => { + // A plain-object cause whose only message is its own `.message` is + // internal text, not a bounded provider message. + const result = providerFailureResult( + new Error('Request failed', { + cause: { message: 'internal maka path /Users/x/.maka/keys.json missing' }, + }), + ); + assert.equal(result.boundedProviderMessage, undefined); + assert.equal(result.message, undefined); + }); + + test('numeric status outranks abort wording anywhere in the chain text (#2521)', () => { + const rateLimited = Object.assign(new Error('request aborted: too many requests'), { + name: 'AI_APICallError', + statusCode: 429, + data: { error: { message: 'request aborted: too many requests' } }, + }); + assert.equal(classifyError(rateLimited), 'RateLimit'); + const unavailable = Object.assign(new Error('request aborted by gateway'), { + name: 'AI_APICallError', + statusCode: 500, + data: { error: { message: 'request aborted by gateway' } }, + }); + assert.equal(classifyError(unavailable), 'ProviderUnavailable'); + // Even a JSON key name carrying "aborted" must not reclassify a 5xx. + assert.equal(classifyError({ statusCode: 500, aborted: false }), 'ProviderUnavailable'); + }); + + test('never reports a text-derived Abort as retryable (#2521)', () => { + const result = providerFailureResult(new Error('The operation was aborted')); + assert.equal(result.errorClass, 'Abort'); + assert.equal(result.retryable, false); + }); + + test('selects the message and the provider code from the same chain link (#2521)', () => { + const error = Object.assign(new Error('Request failed'), { + name: 'AI_APICallError', + statusCode: 429, + data: { error: { code: 'rate_limit_exceeded' } }, + cause: { error: { message: 'transport detail: socket hang up' } }, + }); + const result = providerFailureResult(error); + // Classification still sees the aggregated structured code... + assert.equal(result.errorClass, 'RateLimit'); + // ...but the projected message is never stamped with a code from a + // different link. + assert.equal(result.providerCode, undefined); + assert.match(result.message ?? '', /socket hang up/); + assert.doesNotMatch(result.message ?? '', /rate_limit/i); + }); + + test('admits only closed-vocabulary provider codes into the taxonomy input (#2521)', () => { + assert.equal(taxonomySafeProviderCode('rate_limit_exceeded'), 'rate_limit_exceeded'); + assert.equal(taxonomySafeProviderCode('429'), '429'); + // Free-form tokens containing taxonomy-matched substrings are refused. + assert.equal(taxonomySafeProviderCode('tool_choice_invalid'), undefined); + assert.equal(taxonomySafeProviderCode('auth_custom_scheme'), undefined); + assert.equal(taxonomySafeProviderCode(undefined), undefined); + }); + test('maps provider classes to stable user-safe presentations', () => { assert.deepEqual(errorPresentationFromClass('ProviderBilling'), { reason: 'provider_billing', diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 1cdc757d9f..3c00348664 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -61,6 +61,7 @@ import { errorPresentationFromClass, providerFailureResult, providerRetryMetadata, + taxonomySafeProviderCode, } from './provider-error-classification.js'; import type { ProviderRequestTracker } from './provider-request-telemetry.js'; import type { ContextDiagnosticsCompaction } from './context-diagnostics.js'; @@ -905,12 +906,16 @@ function normalizeProviderFailure(error: unknown): ModelFailure { // presence of a code (Error.code and provider codes both exist here). const kind = modelFailureKind(errorClass); const boundedProviderMessage = kind === 'unknown' && result.boundedProviderMessage === true; + // Only a closed vocabulary of provider codes may enter `code`: this field + // falls back into the Host's terminal-state taxonomy (`failureClass`), so a + // provider's free-form token must never steer it (#2521). + const code = taxonomySafeProviderCode(result.providerCode); return { type: 'model_failure', kind, retryable: result.retryable, ...(result.retryAfterMs !== undefined ? { retryAfterMs: result.retryAfterMs } : {}), - ...(result.providerCode !== undefined ? { code: result.providerCode } : {}), + ...(code !== undefined ? { code } : {}), message: boundedProviderMessage && result.message ? result.message diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 81d90931d3..718c4cdd7a 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -67,6 +67,39 @@ const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ */ const PROVIDER_CAPACITY_CODES: ReadonlySet = new Set(['resource-exhausted']); +/** + * Closed vocabulary check for provider codes that may feed Maka's + * terminal-state taxonomy (`ErrorEvent.code` → `failureClass`). A provider's + * free-form code string is display metadata; an arbitrary token such as + * `tool_choice_invalid` must never steer the Host-owned taxonomy or the + * Desktop label/recovery matching that reads it (#2521). + */ +export function taxonomySafeProviderCode(code: string | undefined): string | undefined { + if (code === undefined) return undefined; + const lower = code.toLowerCase(); + if ( + // Maka-owned sentinels are deterministic, not provider free-form wording. + RUNTIME_RETRYABLE_ERROR_CODES.has(code) || + // ContinuationReplayEmptyError.code (ai-sdk-backend): a Maka-owned class. + lower === 'continuation_replay_empty' || + // Recognized provider outage codes are closed-vocabulary evidence, not + // free-form tokens (e.g. OpenAI-compatible stream errors omit the status). + PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(lower) || + PROVIDER_AUTH_CODES.has(lower) || + PROVIDER_BILLING_CODES.has(lower) || + PROVIDER_PERMISSION_CODES.has(lower) || + PROVIDER_USAGE_LIMIT_CODES.has(lower) || + PROVIDER_RATE_LIMIT_CODES.has(lower) || + PROVIDER_UNAVAILABLE_CODES.has(lower) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(lower) || + /^[1-5]\d{2}$/.test(lower) + ) { + return code; + } + return undefined; +} +>>>>>>> dfd600cf7 (fix(runtime): tighten provider failure provenance and taxonomy inputs) + /** * A provider failure normalized into classification evidence. classifyError's * real input domain is NOT just Error instances: a request-level failure is @@ -96,6 +129,9 @@ interface ProviderErrorFacts { messageSources?: ProviderFailureSources; boundedProviderMessageSource?: boolean; bareMessage?: string; + /** The chain link the projected message was selected from; its code/status + * are the only ones that may be presented alongside that message. */ + messageLink?: ProviderErrorFacts; responseHeaders?: Record; } @@ -180,8 +216,12 @@ function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetr const status = Number(evidence.statusCode || evidence.code); const errorClass = classifyProviderFacts(facts); // Account state cannot be repaired by immediately repeating the same - // physical request, even when a provider reports it through HTTP 429. + // physical request, even when a provider reports it through HTTP 429. An + // Abort is by definition not repairable by repeating the request either — + // the RetryError early return already says so, and a text-derived Abort + // must not fall through to the 5xx retryable rule with the opposite answer. if ( + errorClass === 'Abort' || errorClass === 'Auth' || errorClass === 'ProviderBilling' || errorClass === 'ProviderPermission' || @@ -353,9 +393,10 @@ export function providerFailureSummary(error: unknown): ProviderFailureSummary | function providerFailureSummaryFromFacts( facts: ProviderErrorFacts, ): ProviderFailureSummaryEvidence | undefined { - const sources = facts.summarySources; - const message = firstProviderMessage(facts); - const code = strongestProviderCode(facts); + const link = facts.messageLink; + const sources = link ? link.summarySources : facts.summarySources; + const message = link ? firstProviderMessage(link) : undefined; + const code = strongestProviderCode(link ?? facts); const statusCode = firstProviderField(sources, ['statusCode', 'status']); const requestId = firstProviderField(sources, ['requestId', 'request_id']) ?? @@ -426,7 +467,10 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { : classified, httpStatus, ); - const providerCode = strongestProviderCode(facts); + // When a provider message is projected, its code must come from the same + // chain link — never an outer link's code paired with an inner link's + // message (#2521). + const providerCode = strongestProviderCode(facts.messageLink ?? facts); const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? boundedProviderField(facts.responseHeaders?.['x-request-id']); @@ -511,12 +555,17 @@ function providerFailureDiagnosticFacts(error: unknown): ProviderErrorFacts | un // status and retry hints remain available as fallback evidence. const providerFirst = [...chain].reverse(); const messageFacts = providerFirst.filter((facts) => hasProviderMessageSource(facts)); + // The message and its paired code/status must come from the SAME chain + // link: an inner link's transport message stamped with an outer link's + // provider code presents a sentence the provider never said (#2521). + const messageLink = messageFacts.find((facts) => firstProviderMessage(facts) !== undefined); const responseHeaders = Object.assign({}, ...chain.map((facts) => facts.responseHeaders ?? {})) as | Record | undefined; const bareMessage = messageFacts.find((facts) => facts.bareMessage)?.bareMessage; return { target: chain[0]!.target, + ...(messageLink ? { messageLink } : {}), evidence: { text: chain.map((facts) => facts.evidence.text).join(' '), statusCode: chain.find((facts) => facts.evidence.statusCode)?.evidence.statusCode ?? '', @@ -582,7 +631,12 @@ function hasProviderMessageSource(facts: ProviderErrorFacts): boolean { if (facts.boundedProviderMessageSource !== undefined) { return facts.boundedProviderMessageSource; } - if (!(facts.target instanceof Error)) return true; + // Positive provider provenance is required for every link shape: the link's + // own `.message` — Error or plain object alike — is internal text until a + // nested provider source (data/error/responseBody payloads) or a string + // error carries provider wording. A bare `{ message }` cause from our own + // code or a transport shim must not be certified as a bounded provider + // message (#2521). const targetRecord = objectRecord(facts.target); return ( facts.summarySources.records.some( @@ -762,6 +816,7 @@ export function isContextOverflowErrorText(text: string): boolean { /** * Classifies a provider error by DESCENDING evidence strength over the * normalized evidence (Error, string, or plain stream-error-part object): +<<<<<<< HEAD * abort wrapper → structured account state (capacity, overflow, auth, * billing, permission, usage/rate limits) → numeric HTTP fallbacks (402, 429, * 401; numeric fields, never substrings) → bare 413 @@ -772,6 +827,18 @@ export function isContextOverflowErrorText(text: string): boolean { * message; specific overflow evidence outranks a generic 5xx because proxies * (LiteLLM) wrap provider overflows in 503s; the weak heuristics rank last so * "generate" can never become a rate limit. +======= + * abort wrapper → structured account state → the provider's structured + * overflow code → numeric status fallbacks (402, 429, 401; numeric fields, + * never substrings) → bare 413 + * (HTTP: request entity too large — itself input-side evidence, Cerebras sends it with no body) → + * vetoable free-text overflow relations → generic 5xx → free-text abort → weak word + * heuristics. Specific overflow evidence outranks a generic 5xx because + * proxies (LiteLLM) wrap provider overflows in 503s; the weak heuristics + * rank last so "generate" can never become a rate limit. Numeric status + * outranks free text because the text spans the whole chain, JSON key names + * included. +>>>>>>> dfd600cf7 (fix(runtime): tighten provider failure provenance and taxonomy inputs) */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -809,7 +876,11 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { // The provider's structured context code is unconditional input-overflow // evidence. It outranks outer transport status and message fallbacks. if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; - if (text.includes('abort')) return 'Abort'; + // Numeric status outranks free text: the text spans the whole cause chain + // including JSON key names, so a 429 whose body reads "request aborted: + // too many requests" must keep its rate-limit class and retry-after + // handling, and a 500 carrying an `aborted` key stays ProviderUnavailable + // (#2521). if (statusCode === '402' || code === '402') return 'ProviderBilling'; if (statusCode === '429' || code === '429') return 'RateLimit'; if (statusCode === '401' || code === '401') return 'Auth'; @@ -822,6 +893,7 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { if (structuredCodes.some((c) => PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(c))) return 'ProviderUnavailable'; if (/^5\d\d$/.test(statusCode) || /^5\d\d$/.test(code)) return 'ProviderUnavailable'; + if (text.includes('abort')) return 'Abort'; // Weak word heuristics remain as compatibility fallbacks after all stronger // provider facts. They must not override a structured account state. if (/\brate\b|rate[_-]?limit/.test(text)) return 'RateLimit'; From d9e4316181e64b2fe3c9bbfd9dc86c30f422c7d4 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 23 Aug 2026 18:24:26 +0800 Subject: [PATCH 16/18] chore(core): restore the ASF header on provider-failure after the rebase Generated-by: maka --- .../provider-failure-presentation.test.ts | 19 +++++++++++++++++++ packages/core/src/provider-failure.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts index f814e7921a..142efcf734 100644 --- a/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/provider-failure-presentation.test.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { SessionEvent } from '@maka/core/events'; diff --git a/packages/core/src/provider-failure.ts b/packages/core/src/provider-failure.ts index 1e3e57868d..426d6069ed 100644 --- a/packages/core/src/provider-failure.ts +++ b/packages/core/src/provider-failure.ts @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + export const PROVIDER_FAILURE_CLASSES = [ 'Abort', 'Auth', From c10b32fa00aae93cc83e8d36065e0f360acd7842 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sun, 23 Aug 2026 22:48:52 +0800 Subject: [PATCH 17/18] fix(runtime,core): integrate provider capacity with the unified failure authority Main's #3365 added ProviderCapacity classification while this branch was queued. Fold it into the unified provider-failure taxonomy: the class joins the core union, structured capacity codes rank above account-state codes, and capacity stays retryable through the shared retry metadata. Generated-by: maka --- .../src/renderer/locales/conversation-copy.ts | 6 +-- packages/core/src/provider-failure.ts | 1 + .../src/provider-error-classification.ts | 45 ++++--------------- 3 files changed, 12 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index c40b96449d..c0a6bb7caa 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -582,8 +582,7 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, - turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerPermission: '模型服务拒绝访问', rateLimit: '触发模型速率限制', usageLimit: '模型使用额度已用完', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', account: '检查模型服务的额度、套餐或恢复时间', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, + turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', providerCapacity: '模型服务暂时满载,请稍后重试或切换模型', providerPermission: '模型服务拒绝访问', usageLimit: '模型使用额度已用完', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', account: '检查模型服务的额度、套餐或恢复时间', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', capacity: '模型服务暂时满载,请等待几分钟或切换模型后重试', contextOverflow: '上下文仍超出限制,请减少附件或开启新任务', sandboxBoundaryClosed: '访问范围没有放开,重试本轮后可重新决定' } }, }, en: { actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', returnLatest: 'Return to latest', scrollMainToBottom: 'Scroll main conversation to bottom' }, @@ -786,8 +785,7 @@ const COPY = { reauth: { label: 'Last connection test failed authentication', tooltip: 'The latest test returned 401 / 403. Sending is not blocked, but sign in again under Settings · Models if it fails.' }, testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' }, }, - turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, - turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerPermission: 'Provider access denied', rateLimit: 'Model rate limit reached', usageLimit: 'Model usage limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', account: 'Check the provider allowance, plan, or reset time', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, + turnError: { unknown: 'Unknown error', contextOverflow: 'Context window exceeded', timeout: 'Request timed out', auth: 'Authentication failed', providerBilling: 'Provider billing required', providerCapacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', providerPermission: 'Provider access denied', usageLimit: 'Model usage limit reached', rateLimit: 'Model rate limit reached', network: 'Network error', provider: 'Model service error', stepCap: 'Tool-step limit reached', tool: 'Tool call failed', permission: 'Waiting for permission', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied', recovery: { safeResume: 'Inspect the current state, then try safe recovery', stepCap: 'The task may be incomplete; continue from here', toolError: 'Inspect the tool result before retrying', connection: 'Check the model connection or sign-in status', account: 'Check the provider allowance, plan, or reset time', partial: 'Partial output was retained; continue from here', toolRecord: 'Tool history was retained; inspect it before retrying', retry: 'No tools ran; retry directly', capacity: 'The model service is at capacity. Wait a few minutes or switch models before retrying.', contextOverflow: 'Context is still too large; reduce attachments or start a new task', sandboxBoundaryClosed: 'Access was not widened; retry the turn to decide again' } }, }, } satisfies UiCatalog; diff --git a/packages/core/src/provider-failure.ts b/packages/core/src/provider-failure.ts index 426d6069ed..32021dd221 100644 --- a/packages/core/src/provider-failure.ts +++ b/packages/core/src/provider-failure.ts @@ -24,6 +24,7 @@ export const PROVIDER_FAILURE_CLASSES = [ 'Network', 'Other', 'ProviderBilling', + 'ProviderCapacity', 'ProviderPermission', 'ProviderUnavailable', 'RateLimit', diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 718c4cdd7a..04cb5969a4 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -58,13 +58,10 @@ const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ 'provider_overloaded', 'provider_unavailable', ]); - -/** - * xAI emits this code for transient model capacity failures, including when - * the same payload is relayed through an OpenAI-compatible gateway. Do not - * add the generic gRPC/Google `resource_exhausted` spelling here: that code - * represents quota exhaustion and needs different user guidance. - */ +// xAI emits this code for transient model capacity failures, including when +// the same payload is relayed through an OpenAI-compatible gateway. Do not +// add the generic gRPC/Google `resource_exhausted` spelling here: that code +// represents quota exhaustion and needs different user guidance. const PROVIDER_CAPACITY_CODES: ReadonlySet = new Set(['resource-exhausted']); /** @@ -98,7 +95,6 @@ export function taxonomySafeProviderCode(code: string | undefined): string | und } return undefined; } ->>>>>>> dfd600cf7 (fix(runtime): tighten provider failure provenance and taxonomy inputs) /** * A provider failure normalized into classification evidence. classifyError's @@ -514,12 +510,10 @@ function normalizedProviderFailureClass( classified: string, httpStatus: number | undefined, ): ProviderFailureResult['errorClass'] { - // The semantic class already includes structured provider identifiers - // (capacity and context-overflow included) and therefore outranks the - // transport status used only as a fallback — structured capacity evidence - // can legitimately arrive behind a generic 4xx/5xx proxy response. + // The semantic class already includes structured provider identifiers and + // therefore outranks the transport status used only as a fallback. if (isProviderFailureClass(classified) && classified !== 'Other') return classified; - if (httpStatus === 401 || httpStatus === 403) return 'Auth'; + if (httpStatus === 401) return 'Auth'; if (httpStatus === 402) return 'ProviderBilling'; if (httpStatus === 408) return 'Timeout'; if (httpStatus === 413) return 'ContextLength'; @@ -816,18 +810,6 @@ export function isContextOverflowErrorText(text: string): boolean { /** * Classifies a provider error by DESCENDING evidence strength over the * normalized evidence (Error, string, or plain stream-error-part object): -<<<<<<< HEAD - * abort wrapper → structured account state (capacity, overflow, auth, - * billing, permission, usage/rate limits) → numeric HTTP fallbacks (402, 429, - * 401; numeric fields, never substrings) → bare 413 - * (HTTP: request entity too large — itself input-side evidence, Cerebras sends - * it with no body) → vetoable free-text relations → generic 5xx → weak word - * heuristics. Exact provider evidence outranks generic HTTP/text evidence - * because gateways can wrap a provider failure in a misleading status or - * message; specific overflow evidence outranks a generic 5xx because proxies - * (LiteLLM) wrap provider overflows in 503s; the weak heuristics rank last so - * "generate" can never become a rate limit. -======= * abort wrapper → structured account state → the provider's structured * overflow code → numeric status fallbacks (402, 429, 401; numeric fields, * never substrings) → bare 413 @@ -838,7 +820,6 @@ export function isContextOverflowErrorText(text: string): boolean { * rank last so "generate" can never become a rate limit. Numeric status * outranks free text because the text spans the whole chain, JSON key names * included. ->>>>>>> dfd600cf7 (fix(runtime): tighten provider failure provenance and taxonomy inputs) */ export function classifyError(error: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -851,20 +832,14 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { const { text, statusCode, code, structuredCodes } = evidence; const normalizedCode = code.toLowerCase(); if (code === OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR) return 'Network'; + // Structured capacity evidence outranks account-state codes: a gateway can + // surface capacity as a quota-shaped error, and the retry guidance differs. if ( PROVIDER_CAPACITY_CODES.has(normalizedCode) || structuredCodes.some((c) => PROVIDER_CAPACITY_CODES.has(c)) ) { return 'ProviderCapacity'; } - // Structured provider evidence: the parsed error JSON's code/type is the - // only unconditional signal for a context overflow. - if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) return 'ContextLength'; - if (text.includes('abort')) return 'Abort'; - if (statusCode === '402' || code === '402') return 'ProviderBilling'; - if (statusCode === '429' || code === '429') return 'RateLimit'; - if (statusCode === '401' || statusCode === '403' || code === '401' || code === '403') - return 'Auth'; if (structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) return 'Auth'; if (structuredCodes.some((value) => PROVIDER_BILLING_CODES.has(value))) return 'ProviderBilling'; if (structuredCodes.some((value) => PROVIDER_PERMISSION_CODES.has(value))) @@ -921,8 +896,6 @@ export function errorPresentationFromClass(errorClass: string): { return { reason: 'auth', message: 'Authentication failed' }; case 'ProviderBilling': return { reason: 'provider_billing', message: 'Provider billing required' }; - case 'ProviderCapacity': - return { reason: 'provider_capacity', message: 'Model service is temporarily at capacity' }; case 'ProviderPermission': return { reason: 'provider_permission', message: 'Provider access denied' }; case 'ProviderUnavailable': From b2b235152e953d89cb7405db9fc1618bdf5931ef Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 24 Aug 2026 05:54:21 +0800 Subject: [PATCH 18/18] fix(runtime,cli): derive retry decisions from the normalized failure class A provider envelope whose nested status contradicts its weak wording (e.g. an "authentication" phrase behind HTTP 500) was classified ProviderUnavailable by the failure projection but re-classified Auth by the retry owner, skipping a transient 5xx. Both paths now share one normalized classification and the same numeric-status resolution. Also surfaces the Runtime-owned provider_capacity reason in the CLI transcript notice map so an empty-text capacity notice keeps the wait/switch-model guidance, and feeds nested data.error codes/status into classification evidence for JSON-shaped errors. Generated-by: maka --- .../cli/src/__tests__/pi-transcript.test.ts | 32 + packages/cli/src/pi-transcript.ts | 2 + packages/runtime-host/src/protocol/index.ts | 12 +- packages/runtime/dist-iso/old-pec.cjs | 813 +++++++++++++++++ .../dist-iso/provider-error-classification.js | 840 +++++++++++++++++ .../provider-error-classification.js | 856 ++++++++++++++++++ .../src/provider-error-classification.ts | 66 +- 7 files changed, 2597 insertions(+), 24 deletions(-) create mode 100644 packages/runtime/dist-iso/old-pec.cjs create mode 100644 packages/runtime/dist-iso/provider-error-classification.js create mode 100644 packages/runtime/dist-iso2/provider-error-classification.js diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 90d4343a12..c558a618c2 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -279,6 +279,38 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(chinese, /Context window exceeded/); }); + test('renders the capacity guidance when an empty-text notice carries provider_capacity', () => { + for (const uiLocale of ['en', 'zh'] as const) { + const state = createMakaPiTranscriptState(); + + applyMakaSessionEventToTranscript( + state, + event({ + type: 'error', + recoverable: false, + reason: 'provider_capacity', + message: '', + }), + ); + + assert.deepEqual(state.entries.at(-1), { + kind: 'notice', + level: 'error', + text: '', + runtimeError: { reason: 'provider_capacity' }, + }); + const rendered = renderMakaPiTranscript(state, { ...meta(), uiLocale }, 100) + .map(stripAnsi) + .join('\n'); + if (uiLocale === 'zh') { + assert.match(rendered, /模型服务暂时满载/); + } else { + assert.match(rendered, /temporarily at capacity/); + } + assert.doesNotMatch(rendered, /Try again later\./); + } + }); + test('renders a marked provider summary without inventing a code requirement', () => { const state = createMakaPiTranscriptState(); diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index cd92fc0343..0dcf4589d4 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1810,6 +1810,7 @@ function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): str timeout: '请求超时', auth: '鉴权失败', provider_billing: '模型服务计费受限', + provider_capacity: '模型服务暂时满载,请稍后重试或切换模型', provider_permission: '模型服务拒绝访问', provider_unavailable: '模型服务返回错误', rate_limit: '触发模型速率限制', @@ -1822,6 +1823,7 @@ function transcriptErrorMessage(entry: MakaPiNoticeEntry, locale: UiLocale): str timeout: 'Request timed out', auth: 'Authentication failed', provider_billing: 'Provider billing required', + provider_capacity: 'The model service is temporarily at capacity. Wait and retry, or switch models.', provider_permission: 'Provider access denied', provider_unavailable: 'Provider returned an error', rate_limit: 'Rate limit exceeded', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index e484cda182..80c2b573d8 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,18 +91,14 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -<<<<<<< HEAD -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 44 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 45 as const; +// 45: Durable provider failures may classify as `ProviderPermission`; older +// peers reject that strict error-class value, so mixed versions must fail +// handshake before the class can surface. // 44: Session continuity and inspection stop carrying the retired Session // last-used timestamp. Older peers reject those strict projection shapes. // 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. // Older peers reject that added closed-union field. -======= -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 43 as const; -// 43: Durable provider failures may classify as `ProviderPermission`; older -// peers reject that strict error-class value, so mixed versions must fail -// handshake before the class can surface. ->>>>>>> 1e6c4a139 (fix(runtime-host): advance provider failure epoch) // 42: Turn provider retry progress adds `provider_capacity`. Older peers reject // that strict retry-reason enum value, so mixed versions must fail handshake. // 41: Context compaction returns a typed terminal outcome on both Turn diff --git a/packages/runtime/dist-iso/old-pec.cjs b/packages/runtime/dist-iso/old-pec.cjs new file mode 100644 index 0000000000..4b87a71340 --- /dev/null +++ b/packages/runtime/dist-iso/old-pec.cjs @@ -0,0 +1,813 @@ +"use strict"; +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.taxonomySafeProviderCode = taxonomySafeProviderCode; +exports.providerRetryMetadata = providerRetryMetadata; +exports.providerFailureSummary = providerFailureSummary; +exports.providerFailureDiagnostic = providerFailureDiagnostic; +exports.providerFailureResult = providerFailureResult; +exports.isContextOverflowErrorText = isContextOverflowErrorText; +exports.classifyError = classifyError; +exports.errorPresentationFromClass = errorPresentationFromClass; +const ai_1 = require("ai"); +const diagnostic_log_1 = require("@maka/core/diagnostic-log"); +const provider_failure_1 = require("@maka/core/provider-failure"); +const redaction_1 = require("@maka/core/redaction"); +/** + * Structured provider error identifiers that mean the INPUT exceeded the + * model's context window. These come from the provider's error JSON and are + * the ONLY unconditional overflow evidence: free-text signals are vetoable. + */ +const CONTEXT_OVERFLOW_PROVIDER_CODES = new Set([ + 'context_length_exceeded', // OpenAI & OpenAI-compatible: error.code + 'model_context_window_exceeded', // z.ai: error.code + 'request_too_large', // Anthropic byte-size overflow (HTTP 413): error.type +]); +/** Stable account-state meanings exposed by provider-owned structured fields. */ +const PROVIDER_AUTH_CODES = new Set([ + 'authentication', + 'authentication_error', + 'invalid_api_key', +]); +const PROVIDER_BILLING_CODES = new Set([ + 'insufficient_quota', + 'payment_required', +]); +const PROVIDER_PERMISSION_CODES = new Set(['permission_denied']); +const PROVIDER_USAGE_LIMIT_CODES = new Set(['usage_limit_reached']); +const PROVIDER_RATE_LIMIT_CODES = new Set([ + 'rate_limit_error', + 'rate_limit_exceeded', + 'rate_limited', +]); +const PROVIDER_UNAVAILABLE_PROVIDER_CODES = new Set([ + 'server_error', // OpenAI-compatible stream errors can omit the HTTP status. +]); +const PROVIDER_UNAVAILABLE_CODES = new Set([ + 'overloaded_error', + 'provider_overloaded', + 'provider_unavailable', +]); +/** + * Closed vocabulary check for provider codes that may feed Maka's + * terminal-state taxonomy (`ErrorEvent.code` → `failureClass`). A provider's + * free-form code string is display metadata; an arbitrary token such as + * `tool_choice_invalid` must never steer the Host-owned taxonomy or the + * Desktop label/recovery matching that reads it (#2521). + */ +function taxonomySafeProviderCode(code) { + if (code === undefined) + return undefined; + const lower = code.toLowerCase(); + if ( + // Maka-owned sentinels are deterministic, not provider free-form wording. + RUNTIME_RETRYABLE_ERROR_CODES.has(code) || + // ContinuationReplayEmptyError.code (ai-sdk-backend): a Maka-owned class. + lower === 'continuation_replay_empty' || + // Recognized provider outage codes are closed-vocabulary evidence, not + // free-form tokens (e.g. OpenAI-compatible stream errors omit the status). + PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(lower) || + PROVIDER_AUTH_CODES.has(lower) || + PROVIDER_BILLING_CODES.has(lower) || + PROVIDER_PERMISSION_CODES.has(lower) || + PROVIDER_USAGE_LIMIT_CODES.has(lower) || + PROVIDER_RATE_LIMIT_CODES.has(lower) || + PROVIDER_UNAVAILABLE_CODES.has(lower) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(lower) || + /^[1-5]\d{2}$/.test(lower)) { + return code; + } + return undefined; +} +const PROVIDER_FAILURE_SUMMARY_MAX_BYTES = 2 * 1024; +const PROVIDER_FAILURE_FIELD_MAX_BYTES = 256; +const MAX_SAFE_TIMER_DELAY_MS = 2_147_483_647; +const OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR = 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR'; +const RUNTIME_RETRYABLE_ERROR_CODES = new Set([ + OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR, + 'OPENAI_RESPONSES_CONTINUATION_UNAVAILABLE', +]); +function providerErrorTarget(error) { + return ai_1.RetryError.isInstance(error) && error.lastError !== undefined && error.lastError !== error + ? error.lastError + : error; +} +function responseHeadersFromError(error) { + if (typeof error !== 'object' || error === null) + return undefined; + const value = error.responseHeaders; + if (typeof value !== 'object' || value === null) + return undefined; + const headers = {}; + for (const [key, header] of Object.entries(value)) { + if (typeof header === 'string') + headers[key.toLowerCase()] = header; + } + return headers; +} +function parseRetryAfterMs(headers) { + const rawMilliseconds = headers['retry-after-ms']; + const rawRetryAfter = headers['retry-after']; + if (rawMilliseconds === undefined && rawRetryAfter === undefined) + return undefined; + let delayMs; + if (rawMilliseconds !== undefined) { + delayMs = Number(rawMilliseconds); + } + else { + const seconds = Number(rawRetryAfter); + delayMs = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(rawRetryAfter) - Date.now(); + } + if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > MAX_SAFE_TIMER_DELAY_MS) + return null; + return Math.ceil(delayMs); +} +/** + * Normalizes provider retry facts without leaking SDK error objects or raw + * response headers across the ModelAdapter boundary. + */ +function providerRetryMetadata(error) { + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return { retryable: false }; + return providerRetryMetadataFromFacts(facts); +} +function providerRetryMetadataFromFacts(facts) { + const { evidence } = facts; + if (RUNTIME_RETRYABLE_ERROR_CODES.has(evidence.code)) + return { retryable: true }; + const status = Number(evidence.statusCode || evidence.code); + const errorClass = classifyProviderFacts(facts); + // Account state cannot be repaired by immediately repeating the same + // physical request, even when a provider reports it through HTTP 429. An + // Abort is by definition not repairable by repeating the request either — + // the RetryError early return already says so, and a text-derived Abort + // must not fall through to the 5xx retryable rule with the opposite answer. + if (errorClass === 'Abort' || + errorClass === 'Auth' || + errorClass === 'ProviderBilling' || + errorClass === 'ProviderPermission' || + errorClass === 'UsageLimit' || + errorClass === 'ContextLength') { + return { retryable: false }; + } + const retryAfterMs = parseRetryAfterMs(facts.responseHeaders ?? {}); + if (errorClass === 'RateLimit' || status === 429) { + if (retryAfterMs === undefined || retryAfterMs === null) + return { retryable: false }; + return { retryable: true, retryAfterMs }; + } + const retryable = errorClass === 'Network' || + errorClass === 'ProviderUnavailable' || + status === 408 || + status === 409 || + (status >= 500 && status <= 599); + if (!retryable) + return { retryable: false }; + if (retryAfterMs === null) + return { retryable: false }; + return { + retryable: true, + ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), + }; +} +/** Collects stable identifiers from the provider envelopes Maka receives. */ +function collectStructuredCodes(payload, out) { + const fromRecord = (record) => { + if (!record) + return; + for (const key of ['code', 'type', 'error_type', 'provider_code']) { + const value = safeField(record, key); + if (typeof value === 'string' && value) + out.push(value.toLowerCase()); + } + }; + const record = providerRecord(payload); + fromRecord(record); + if (!record) + return; + fromRecord(providerRecord(safeField(record, 'metadata'))); + const error = providerRecord(safeField(record, 'error')); + fromRecord(error); + fromRecord(error ? providerRecord(safeField(error, 'metadata')) : undefined); + const response = providerRecord(safeField(record, 'response')); + fromRecord(response); + const responseError = response ? providerRecord(safeField(response, 'error')) : undefined; + fromRecord(responseError); + fromRecord(responseError ? providerRecord(safeField(responseError, 'metadata')) : undefined); +} +function normalizeProviderError(error) { + const target = providerErrorTarget(error); + if (target instanceof Error) { + const responseHeaders = responseHeadersFromError(target); + const code = 'code' in target ? String(target.code) : ''; + const statusCode = 'statusCode' in target + ? String(target.statusCode) + : 'status' in target + ? String(target.status) + : ''; + const rawBody = target.responseBody; + const body = typeof rawBody === 'string' ? rawBody : ''; + const structuredCodes = []; + collectStructuredCodes(target, structuredCodes); + collectStructuredCodes(target.data, structuredCodes); + if (body) { + // The failed-response handler keeps the raw body even when the provider + // JSON failed the schema (which is exactly when `data` is absent). + try { + collectStructuredCodes(JSON.parse(body), structuredCodes); + } + catch { + // Not JSON — no structured evidence. + } + } + return { + target, + // The raw body joins the text evidence: when the provider JSON fails + // the error schema, `message` degrades to the statusText and the body + // is the ONLY carrier of the provider's wording (e.g. an + // OpenAI-compatible `{error: string}` overflow). Positives and vetoes + // both run over the same full text. + evidence: { + text: `${target.name} ${code} ${statusCode} ${target.message}${body ? ` ${body}` : ''}`.toLowerCase(), + statusCode, + code, + structuredCodes, + }, + summarySources: providerFailureSources(target), + ...(responseHeaders ? { responseHeaders } : {}), + }; + } + if (typeof target === 'string') { + const parsed = parsedProviderValue(target); + const structuredCodes = []; + if (parsed !== undefined) + collectStructuredCodes(parsed, structuredCodes); + return { + target, + evidence: { text: target.toLowerCase(), statusCode: '', code: '', structuredCodes }, + summarySources: providerFailureSources(parsed), + ...(parsed === undefined + ? { bareMessage: target } + : typeof parsed === 'string' + ? { bareMessage: parsed } + : {}), + }; + } + if (typeof target === 'object' && target !== null) { + const record = target; + const responseHeaders = responseHeadersFromError(target); + const field = (key) => { + const value = record[key]; + return typeof value === 'string' || typeof value === 'number' ? String(value) : ''; + }; + const structuredCodes = []; + collectStructuredCodes(record, structuredCodes); + const rawBody = safeField(record, 'responseBody'); + if (typeof rawBody === 'string') { + const parsedBody = parsedProviderValue(rawBody); + if (parsedBody !== undefined) + collectStructuredCodes(parsedBody, structuredCodes); + } + let text; + try { + // Serialize the whole value so message/code text is evidence no matter + // which of the known provider shapes carried it. + text = JSON.stringify(target).toLowerCase(); + } + catch { + text = String(target).toLowerCase(); + } + return { + target, + evidence: { + text, + statusCode: field('statusCode') || field('status'), + code: field('code'), + structuredCodes, + }, + summarySources: providerFailureSources(target), + ...(responseHeaders ? { responseHeaders } : {}), + }; + } + return undefined; +} +/** + * Retains only allowlisted provider failure fields. The provider value may also + * contain request bodies, headers, or credentials, so it must never be copied + * or serialized as diagnostic output wholesale. + */ +function providerFailureSummary(error) { + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return undefined; + const summary = providerFailureSummaryFromFacts(facts); + if (!summary) + return undefined; + return { + message: summary.message, + ...(summary.code !== undefined ? { code: summary.code } : {}), + }; +} +function providerFailureSummaryFromFacts(facts) { + const link = facts.messageLink; + const sources = link ? link.summarySources : facts.summarySources; + const message = link ? firstProviderMessage(link) : undefined; + const code = strongestProviderCode(link ?? facts); + const statusCode = firstProviderField(sources, ['statusCode', 'status']); + const requestId = firstProviderField(sources, ['requestId', 'request_id']) ?? + boundedProviderField(facts.responseHeaders?.['x-request-id']); + const metadata = [ + ...(code ? [`code=${code}`] : []), + ...(statusCode && statusCode !== code ? [`status=${statusCode}`] : []), + ...(requestId ? [`requestId=${requestId}`] : []), + ]; + if (!message && metadata.length === 0) + return undefined; + const suffix = metadata.length > 0 ? ` (${metadata.join(', ')})` : ''; + const messageBudget = Math.max(1, PROVIDER_FAILURE_SUMMARY_MAX_BYTES - Buffer.byteLength(suffix, 'utf8')); + const summary = `${(0, diagnostic_log_1.truncateUtf8)((0, redaction_1.redactSecrets)(message ?? 'Provider request failed'), messageBudget, '…')}${suffix}`; + return { + message: (0, diagnostic_log_1.truncateUtf8)(summary, PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'), + ...(code || statusCode ? { code: code ?? statusCode } : {}), + boundedProviderMessage: message !== undefined && hasProviderMessageSource(facts), + }; +} +/** + * Projects provider errors into a small durable fingerprint. Unlike the + * presentation summary, this intentionally excludes provider messages and + * response bodies: even redacted free text can echo prompts or credentials. + */ +function providerFailureDiagnostic(error) { + const failure = providerFailureResult(error); + return { + errorClass: failure.errorClass, + ...(failure.httpStatus !== undefined ? { httpStatus: failure.httpStatus } : {}), + ...(failure.providerCode !== undefined ? { providerCode: failure.providerCode } : {}), + ...(failure.providerRequestId !== undefined + ? { providerRequestId: failure.providerRequestId } + : {}), + retryable: failure.retryable, + }; +} +/** The single structured provider-failure authority for Runtime consumers. */ +function providerFailureResult(error) { + if (ai_1.RetryError.isInstance(error) && error.reason === 'abort') { + return { errorClass: 'Abort', retryable: false }; + } + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return { errorClass: 'Other', retryable: false }; + const sources = facts.summarySources; + const rawStatus = facts.evidence.statusCode || firstProviderField(sources, ['statusCode', 'status']); + const numericStatus = Number(rawStatus); + const httpStatus = Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 + ? numericStatus + : undefined; + const classified = classifyProviderFacts(facts); + const errorClass = normalizedProviderFailureClass(classified === 'Auth' && + httpStatus !== undefined && + httpStatus !== 401 && + !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value)) + ? 'Other' + : classified, httpStatus); + // When a provider message is projected, its code must come from the same + // chain link — never an outer link's code paired with an inner link's + // message (#2521). + const providerCode = strongestProviderCode(facts.messageLink ?? facts); + const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? + boundedProviderField(facts.responseHeaders?.['x-request-id']); + const retry = providerRetryMetadataFromFacts(facts); + const summary = providerFailureSummaryFromFacts(facts); + return { + errorClass, + ...(httpStatus !== undefined ? { httpStatus } : {}), + ...(providerCode !== undefined ? { providerCode } : {}), + ...(providerRequestId !== undefined ? { providerRequestId } : {}), + retryable: retry.retryable, + ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), + ...(summary?.boundedProviderMessage === true + ? { + message: summary.message, + boundedProviderMessage: true, + } + : {}), + }; +} +function strongestProviderCode(facts) { + const semantic = facts.evidence.structuredCodes.find((value) => PROVIDER_AUTH_CODES.has(value) || + PROVIDER_BILLING_CODES.has(value) || + PROVIDER_PERMISSION_CODES.has(value) || + PROVIDER_USAGE_LIMIT_CODES.has(value) || + PROVIDER_RATE_LIMIT_CODES.has(value) || + PROVIDER_UNAVAILABLE_CODES.has(value) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(value)); + return (boundedProviderField(semantic) ?? + firstProviderField(facts.summarySources, ['code']) ?? + firstProviderField(facts.summarySources, ['type'])); +} +function normalizedProviderFailureClass(classified, httpStatus) { + // The semantic class already includes structured provider identifiers and + // therefore outranks the transport status used only as a fallback. + if ((0, provider_failure_1.isProviderFailureClass)(classified) && classified !== 'Other') + return classified; + if (httpStatus === 401) + return 'Auth'; + if (httpStatus === 402) + return 'ProviderBilling'; + if (httpStatus === 408) + return 'Timeout'; + if (httpStatus === 413) + return 'ContextLength'; + if (httpStatus === 429) + return 'RateLimit'; + if (httpStatus !== undefined && httpStatus >= 400 && httpStatus <= 499) { + return 'RequestRejected'; + } + if (httpStatus !== undefined && httpStatus >= 500 && httpStatus <= 599) { + return 'ProviderUnavailable'; + } + return 'Other'; +} +function providerFailureDiagnosticFacts(error) { + let current = providerErrorTarget(error); + const chain = []; + const seen = new Set(); + for (let depth = 0; depth < 4 && current !== undefined && !seen.has(current); depth += 1) { + seen.add(current); + const facts = normalizeProviderError(current); + if (facts) + chain.push(facts); + current = + current && typeof current === 'object' + ? safeField(current, 'cause') + : undefined; + } + if (chain.length === 0) + return undefined; + // Provider semantics can sit below an SDK/transport wrapper that also owns + // the HTTP status. Aggregate the bounded cause chain instead of letting the + // first status-bearing object discard stronger structured codes. Provider + // sources are ordered inner-first for message/code projection; transport + // status and retry hints remain available as fallback evidence. + const providerFirst = [...chain].reverse(); + const messageFacts = providerFirst.filter((facts) => hasProviderMessageSource(facts)); + // The message and its paired code/status must come from the SAME chain + // link: an inner link's transport message stamped with an outer link's + // provider code presents a sentence the provider never said (#2521). + const messageLink = messageFacts.find((facts) => firstProviderMessage(facts) !== undefined); + const responseHeaders = Object.assign({}, ...chain.map((facts) => facts.responseHeaders ?? {})); + const bareMessage = messageFacts.find((facts) => facts.bareMessage)?.bareMessage; + return { + target: chain[0].target, + ...(messageLink ? { messageLink } : {}), + evidence: { + text: chain.map((facts) => facts.evidence.text).join(' '), + statusCode: chain.find((facts) => facts.evidence.statusCode)?.evidence.statusCode ?? '', + code: chain.find((facts) => facts.evidence.code)?.evidence.code ?? '', + structuredCodes: [...new Set(chain.flatMap((facts) => facts.evidence.structuredCodes))], + }, + summarySources: { + records: providerFirst.flatMap((facts) => facts.summarySources.records), + stringErrors: providerFirst.flatMap((facts) => facts.summarySources.stringErrors), + }, + messageSources: { + records: messageFacts.flatMap((facts) => facts.summarySources.records), + stringErrors: messageFacts.flatMap((facts) => facts.summarySources.stringErrors), + }, + boundedProviderMessageSource: messageFacts.length > 0, + ...(bareMessage ? { bareMessage } : {}), + ...(responseHeaders && Object.keys(responseHeaders).length > 0 ? { responseHeaders } : {}), + }; +} +function providerFailureSources(error) { + const record = objectRecord(error); + if (!record) + return { records: [], stringErrors: [] }; + const data = objectRecord(safeField(record, 'data')); + const nestedError = providerRecord(safeField(record, 'error')); + const dataError = providerRecord(data ? safeField(data, 'error') : undefined); + const parsedBody = parsedProviderBody(safeField(record, 'responseBody')); + const parsedBodyError = providerRecord(parsedBody ? safeField(parsedBody, 'error') : undefined); + return { + records: [dataError, data, parsedBodyError, parsedBody, nestedError, record].filter((source) => source !== undefined), + stringErrors: [ + data ? safeField(data, 'error') : undefined, + parsedBody ? safeField(parsedBody, 'error') : undefined, + safeField(record, 'error'), + ], + }; +} +function providerRecord(value) { + return objectRecord(typeof value === 'string' ? (parsedProviderValue(value) ?? value) : value); +} +function firstProviderMessage(facts) { + if (facts.bareMessage !== undefined) + return boundedProviderMessage(facts.bareMessage); + const sources = facts.messageSources ?? facts.summarySources; + const candidates = [ + ...sources.records.map((source) => safeField(source, 'message')), + ...sources.stringErrors, + ]; + return candidates + .map((candidate) => boundedProviderMessage(candidate)) + .find((value) => value !== undefined); +} +function hasProviderMessageSource(facts) { + if (facts.boundedProviderMessageSource !== undefined) { + return facts.boundedProviderMessageSource; + } + // Positive provider provenance is required for every link shape: the link's + // own `.message` — Error or plain object alike — is internal text until a + // nested provider source (data/error/responseBody payloads) or a string + // error carries provider wording. A bare `{ message }` cause from our own + // code or a transport shim must not be certified as a bounded provider + // message (#2521). + const targetRecord = objectRecord(facts.target); + return (facts.summarySources.records.some((source) => source !== targetRecord && + boundedProviderMessage(safeField(source, 'message')) !== undefined) || + facts.summarySources.stringErrors.some((candidate) => boundedProviderMessage(candidate) !== undefined)); +} +function firstProviderField(sources, keys) { + for (const key of keys) { + for (const source of sources.records) { + const value = boundedProviderField(safeField(source, key)); + if (value !== undefined) + return value; + } + } + return undefined; +} +function parsedProviderBody(value) { + if (typeof value !== 'string') + return undefined; + return objectRecord(parsedProviderValue(value)); +} +function parsedProviderValue(value) { + try { + return JSON.parse(value); + } + catch { + return undefined; + } +} +function objectRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value + : undefined; +} +function safeField(record, key) { + try { + return record[key]; + } + catch { + return undefined; + } +} +function boundedProviderField(value) { + if (typeof value !== 'string' && typeof value !== 'number') + return undefined; + const normalized = String(value).trim(); + if (!normalized) + return undefined; + return (0, diagnostic_log_1.truncateUtf8)((0, redaction_1.redactSecrets)(normalized), PROVIDER_FAILURE_FIELD_MAX_BYTES, '…'); +} +function boundedProviderMessage(value, parseJson = true) { + if (typeof value !== 'string') + return undefined; + let normalized = value.trim(); + if (!normalized) + return undefined; + if (parseJson) { + const parsed = parsedProviderValue(normalized); + if (parsed !== undefined) { + if (typeof parsed === 'string') { + normalized = parsed.trim(); + } + else { + const sources = providerFailureSources(parsed); + const candidates = [ + ...sources.records.map((source) => safeField(source, 'message')), + ...sources.stringErrors, + ]; + return candidates + .map((candidate) => boundedProviderMessage(candidate, false)) + .find((candidate) => candidate !== undefined); + } + } + } + if (!normalized) + return undefined; + return (0, diagnostic_log_1.truncateUtf8)((0, redaction_1.redactSecrets)(normalized), PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'); +} +/** + * Provider context-length overflow signatures. A request-level 400/413 whose + * message matches one of these means the input exceeded the model's context + * window — the reactive-recovery trigger (issue #882 PR 2). The set is ported + * from pi's battle-tested table and covers the providers Maka ships in its + * registry (Anthropic, OpenAI/-compatible, Google, xAI, Groq, OpenRouter, + * Mistral, MiniMax, Kimi/Moonshot, Together, llama.cpp/LM Studio/Ollama, …). + * Matched against the ORIGINAL error's composite fields (name, code, status, + * message), never the generalized string. All of these are free-text evidence + * and can be vetoed by NON_CONTEXT_OVERFLOW_PATTERNS: a capacity statement or + * overflow phrase quoted inside a throttling/quota error must not trigger + * recovery — only a structured provider code is unconditional. + */ +const CONTEXT_OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic token overflow + /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions & Responses) + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))?/i, // OpenAI-compatible proxies (LiteLLM) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter (most backends) + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI + // GitHub Copilot: "prompt token count of X exceeds the limit of Y". The INPUT + // subject is required — a bare "token count of N exceeds the limit of M" also + // matches output/completion caps, and a bare "exceeds the limit of N" matches + // file-size and other quota errors; neither is fixable by history compaction. + /(?:prompt|input|context|message)[^.]{0,80}token count of [\d,]+ exceeds the limit of [\d,]+/i, + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding + /too large for model with \d+ maximum context length/i, // Mistral + /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server + /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text + /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /context[_ ]length[_ ]exceeded/i, // OpenAI structured error code (also generic) + // Ambiguous token-limit wording that is an input overflow only when an + // input-like word is the subject. `request` is deliberately NOT in the + // subject list: it appears in generic prefixes ("Invalid request: ...") + // without saying anything about which side of the token budget overflowed. + /(?:prompt|input|context|message)[^.]{0,80}too many tokens/i, + /(?:prompt|input|context|message)[^.]{0,80}token limit exceeded/i, +]; +/** + * Wording that looks token-shaped but is NOT an input overflow: throttling / + * quota / rate limiting, and complete OUTPUT-cap relations in every observed + * permutation of role word (output/completion/max_tokens) and token + * predicate — subject before predicate ("completion has too many tokens", + * "max_tokens token limit exceeded"), predicate before subject ("too many + * tokens were requested for the completion"), the count-of form ("output + * token count of N exceeds"), the role word embedded inside the phrase + * ("too many completion tokens were requested"), and the role-tokens-exceed + * form ("Maximum completion tokens exceeded"). Noun phrases alone (e.g. + * "completion token count") are not excluded: they also appear as usage + * breakdowns inside genuine input-overflow messages, and "(prompt + + * completion) exceed" combined-budget wording stays classifiable because the + * role word is not adjacent to "tokens". + */ +const NON_CONTEXT_OVERFLOW_PATTERNS = [ + /rate limit/i, + /too many requests/i, + /throttl/i, + /quota/i, + /(?:output|completion|max_tokens)\b[^.]{0,60}(?:too many tokens|token limit exceeded)/i, + /(?:too many tokens|token limit exceeded)[^.]{0,60}\b(?:output|completion|max_tokens)/i, + /(?:output|completion)\s+token\s+(?:count|limit)[^.]{0,40}exceed/i, + /too many (?:output|completion|max_tokens)[^.]{0,20}tokens/i, + /\b(?:output|completion|max_tokens)\s+tokens?\b[^.]{0,20}exceed/i, +]; +/** + * Two-layer overflow detection on an error's raw text (the composite of its + * original name/code/status/message). Triggering recovery requires positive + * evidence of an INPUT overflow — the one class history compaction can fix: + * 1. Vetoes first: throttling/quota wording and complete output-cap relations + * disqualify every free-text signal. Free text is never unconditional — a + * capacity statement quoted inside a throttle error is not an overflow. + * 2. Positive overflow relations count only when nothing vetoed. Structured + * provider codes (the unconditional evidence) are classifyError's job, + * checked before this text layer ever runs. + */ +function isContextOverflowErrorText(text) { + if (!text) + return false; + if (NON_CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(text))) + return false; + return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(text)); +} +/** + * Classifies a provider error by DESCENDING evidence strength over the + * normalized evidence (Error, string, or plain stream-error-part object): + * abort wrapper → structured account state → the provider's structured + * overflow code → numeric status fallbacks (402, 429, 401; numeric fields, + * never substrings) → bare 413 + * (HTTP: request entity too large — itself input-side evidence, Cerebras sends it with no body) → + * vetoable free-text overflow relations → generic 5xx → free-text abort → weak word + * heuristics. Specific overflow evidence outranks a generic 5xx because + * proxies (LiteLLM) wrap provider overflows in 503s; the weak heuristics + * rank last so "generate" can never become a rate limit. Numeric status + * outranks free text because the text spans the whole chain, JSON key names + * included. + */ +function classifyError(error) { + if (ai_1.RetryError.isInstance(error) && error.reason === 'abort') + return 'Abort'; + const facts = providerFailureDiagnosticFacts(error); + return facts ? classifyProviderFacts(facts) : 'Other'; +} +function classifyProviderFacts(facts) { + const { target: classificationTarget, evidence } = facts; + const { text, statusCode, code, structuredCodes } = evidence; + if (code === OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR) + return 'Network'; + if (structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) + return 'Auth'; + if (structuredCodes.some((value) => PROVIDER_BILLING_CODES.has(value))) + return 'ProviderBilling'; + if (structuredCodes.some((value) => PROVIDER_PERMISSION_CODES.has(value))) + return 'ProviderPermission'; + if (structuredCodes.some((value) => PROVIDER_USAGE_LIMIT_CODES.has(value))) + return 'UsageLimit'; + if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) + return 'RateLimit'; + if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) + return 'ProviderUnavailable'; + // The provider's structured context code is unconditional input-overflow + // evidence. It outranks outer transport status and message fallbacks. + if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) + return 'ContextLength'; + // Numeric status outranks free text: the text spans the whole cause chain + // including JSON key names, so a 429 whose body reads "request aborted: + // too many requests" must keep its rate-limit class and retry-after + // handling, and a 500 carrying an `aborted` key stays ProviderUnavailable + // (#2521). + if (statusCode === '402' || code === '402') + return 'ProviderBilling'; + if (statusCode === '429' || code === '429') + return 'RateLimit'; + if (statusCode === '401' || code === '401') + return 'Auth'; + // A bare 403 is intentionally unknown: providers use it for valid-key + // permission failures, guardrails, subscription limits, and occasionally + // authentication. The provider's bounded diagnostic remains available. + if (statusCode === '413' || code === '413') + return 'ContextLength'; + // Free-text overflow relations on the composite text, veto-first inside. + if (isContextOverflowErrorText(text)) + return 'ContextLength'; + if (structuredCodes.some((c) => PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(c))) + return 'ProviderUnavailable'; + if (/^5\d\d$/.test(statusCode) || /^5\d\d$/.test(code)) + return 'ProviderUnavailable'; + if (text.includes('abort')) + return 'Abort'; + // Weak word heuristics remain as compatibility fallbacks after all stronger + // provider facts. They must not override a structured account state. + if (/\brate\b|rate[_-]?limit/.test(text)) + return 'RateLimit'; + if ((0, redaction_1.isAuthenticationErrorText)(text)) + return 'Auth'; + if (text.includes('timeout')) + return 'Timeout'; + if (text.includes('network') || + text.includes('fetch') || + /\btypeerror\b.*\bterminated\b/.test(text)) + return 'Network'; + return classificationTarget instanceof Error ? classificationTarget.name || 'Other' : 'Other'; +} +function errorPresentationFromClass(errorClass) { + switch (errorClass) { + case 'ContextLength': + return { reason: 'context_overflow', message: 'Context window exceeded' }; + case 'Timeout': + return { reason: 'timeout', message: 'Request timed out' }; + case 'Auth': + return { reason: 'auth', message: 'Authentication failed' }; + case 'ProviderBilling': + return { reason: 'provider_billing', message: 'Provider billing required' }; + case 'ProviderPermission': + return { reason: 'provider_permission', message: 'Provider access denied' }; + case 'ProviderUnavailable': + return { reason: 'provider_unavailable', message: 'Provider returned an error' }; + case 'RateLimit': + return { reason: 'rate_limit', message: 'Rate limit exceeded' }; + case 'UsageLimit': + return { reason: 'usage_limit', message: 'Usage limit reached' }; + case 'Network': + return { reason: 'network', message: 'Network error' }; + default: + return {}; + } +} diff --git a/packages/runtime/dist-iso/provider-error-classification.js b/packages/runtime/dist-iso/provider-error-classification.js new file mode 100644 index 0000000000..c894401f8c --- /dev/null +++ b/packages/runtime/dist-iso/provider-error-classification.js @@ -0,0 +1,840 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { RetryError } from 'ai'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { isProviderFailureClass } from '@maka/core/provider-failure'; +import { isAuthenticationErrorText, redactSecrets } from '@maka/core/redaction'; +/** + * Structured provider error identifiers that mean the INPUT exceeded the + * model's context window. These come from the provider's error JSON and are + * the ONLY unconditional overflow evidence: free-text signals are vetoable. + */ +const CONTEXT_OVERFLOW_PROVIDER_CODES = new Set([ + 'context_length_exceeded', // OpenAI & OpenAI-compatible: error.code + 'model_context_window_exceeded', // z.ai: error.code + 'request_too_large', // Anthropic byte-size overflow (HTTP 413): error.type +]); +/** Stable account-state meanings exposed by provider-owned structured fields. */ +const PROVIDER_AUTH_CODES = new Set([ + 'authentication', + 'authentication_error', + 'invalid_api_key', +]); +const PROVIDER_BILLING_CODES = new Set([ + 'insufficient_quota', + 'payment_required', +]); +const PROVIDER_PERMISSION_CODES = new Set(['permission_denied']); +const PROVIDER_USAGE_LIMIT_CODES = new Set(['usage_limit_reached']); +const PROVIDER_RATE_LIMIT_CODES = new Set([ + 'rate_limit_error', + 'rate_limit_exceeded', + 'rate_limited', +]); +const PROVIDER_UNAVAILABLE_PROVIDER_CODES = new Set([ + 'server_error', // OpenAI-compatible stream errors can omit the HTTP status. +]); +const PROVIDER_UNAVAILABLE_CODES = new Set([ + 'overloaded_error', + 'provider_overloaded', + 'provider_unavailable', +]); +// xAI emits this code for transient model capacity failures, including when +// the same payload is relayed through an OpenAI-compatible gateway. Do not +// add the generic gRPC/Google `resource_exhausted` spelling here: that code +// represents quota exhaustion and needs different user guidance. +const PROVIDER_CAPACITY_CODES = new Set(['resource-exhausted']); +/** + * Closed vocabulary check for provider codes that may feed Maka's + * terminal-state taxonomy (`ErrorEvent.code` → `failureClass`). A provider's + * free-form code string is display metadata; an arbitrary token such as + * `tool_choice_invalid` must never steer the Host-owned taxonomy or the + * Desktop label/recovery matching that reads it (#2521). + */ +export function taxonomySafeProviderCode(code) { + if (code === undefined) + return undefined; + const lower = code.toLowerCase(); + if ( + // Maka-owned sentinels are deterministic, not provider free-form wording. + RUNTIME_RETRYABLE_ERROR_CODES.has(code) || + // ContinuationReplayEmptyError.code (ai-sdk-backend): a Maka-owned class. + lower === 'continuation_replay_empty' || + // Recognized provider outage codes are closed-vocabulary evidence, not + // free-form tokens (e.g. OpenAI-compatible stream errors omit the status). + PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(lower) || + PROVIDER_AUTH_CODES.has(lower) || + PROVIDER_BILLING_CODES.has(lower) || + PROVIDER_PERMISSION_CODES.has(lower) || + PROVIDER_USAGE_LIMIT_CODES.has(lower) || + PROVIDER_RATE_LIMIT_CODES.has(lower) || + PROVIDER_UNAVAILABLE_CODES.has(lower) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(lower) || + /^[1-5]\d{2}$/.test(lower)) { + return code; + } + return undefined; +} +const PROVIDER_FAILURE_SUMMARY_MAX_BYTES = 2 * 1024; +const PROVIDER_FAILURE_FIELD_MAX_BYTES = 256; +const MAX_SAFE_TIMER_DELAY_MS = 2_147_483_647; +const OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR = 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR'; +const RUNTIME_RETRYABLE_ERROR_CODES = new Set([ + OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR, + 'OPENAI_RESPONSES_CONTINUATION_UNAVAILABLE', +]); +function providerErrorTarget(error) { + return RetryError.isInstance(error) && error.lastError !== undefined && error.lastError !== error + ? error.lastError + : error; +} +function responseHeadersFromError(error) { + if (typeof error !== 'object' || error === null) + return undefined; + const value = error.responseHeaders; + if (typeof value !== 'object' || value === null) + return undefined; + const headers = {}; + for (const [key, header] of Object.entries(value)) { + if (typeof header === 'string') + headers[key.toLowerCase()] = header; + } + return headers; +} +function parseRetryAfterMs(headers) { + const rawMilliseconds = headers['retry-after-ms']; + const rawRetryAfter = headers['retry-after']; + if (rawMilliseconds === undefined && rawRetryAfter === undefined) + return undefined; + let delayMs; + if (rawMilliseconds !== undefined) { + delayMs = Number(rawMilliseconds); + } + else { + const seconds = Number(rawRetryAfter); + delayMs = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(rawRetryAfter) - Date.now(); + } + if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > MAX_SAFE_TIMER_DELAY_MS) + return null; + return Math.ceil(delayMs); +} +/** + * Normalizes provider retry facts without leaking SDK error objects or raw + * response headers across the ModelAdapter boundary. + */ +export function providerRetryMetadata(error) { + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return { retryable: false }; + return providerRetryMetadataFromFacts(facts); +} +/** + * The classification that retry and failure projections must agree on: a + * weak text-derived `Auth` class is demoted when the envelope's own numeric + * status contradicts it (an "authentication" wording behind a 500 is a + * provider-unavailable event, not an account state), so the Runtime retry + * owner never skips a transient 5xx because of the original wording. + */ +function normalizedClassifiedClass(facts) { + const classified = classifyProviderFacts(facts); + if (classified === 'Auth' && + !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) { + const rawStatus = facts.evidence.statusCode || + firstProviderField(facts.summarySources, ['statusCode', 'status']); + const numericStatus = Number(rawStatus); + const httpStatus = Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 + ? numericStatus + : undefined; + if (httpStatus !== undefined && httpStatus !== 401) + return 'Other'; + } + return classified; +} +function providerRetryMetadataFromFacts(facts) { + const { evidence } = facts; + if (RUNTIME_RETRYABLE_ERROR_CODES.has(evidence.code)) + return { retryable: true }; + const status = Number(evidence.statusCode || evidence.code); + const errorClass = normalizedClassifiedClass(facts); + // Account state cannot be repaired by immediately repeating the same + // physical request, even when a provider reports it through HTTP 429. An + // Abort is by definition not repairable by repeating the request either — + // the RetryError early return already says so, and a text-derived Abort + // must not fall through to the 5xx retryable rule with the opposite answer. + if (errorClass === 'Abort' || + errorClass === 'Auth' || + errorClass === 'ProviderBilling' || + errorClass === 'ProviderPermission' || + errorClass === 'UsageLimit' || + errorClass === 'ContextLength') { + return { retryable: false }; + } + const retryAfterMs = parseRetryAfterMs(facts.responseHeaders ?? {}); + if (errorClass === 'ProviderCapacity') { + // Capacity is transient even when the provider sends a malformed delay; + // fall back to the adapter's bounded local backoff in that case. + return { + retryable: true, + ...(retryAfterMs !== undefined && retryAfterMs !== null ? { retryAfterMs } : {}), + }; + } + if (errorClass === 'RateLimit' || status === 429) { + if (retryAfterMs === undefined || retryAfterMs === null) + return { retryable: false }; + return { retryable: true, retryAfterMs }; + } + const retryable = errorClass === 'Network' || + errorClass === 'ProviderUnavailable' || + status === 408 || + status === 409 || + (status >= 500 && status <= 599); + if (!retryable) + return { retryable: false }; + if (retryAfterMs === null) + return { retryable: false }; + return { + retryable: true, + ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), + }; +} +/** Collects stable identifiers from the provider envelopes Maka receives. */ +function collectStructuredCodes(payload, out) { + const fromRecord = (record) => { + if (!record) + return; + for (const key of ['code', 'type', 'error_type', 'provider_code']) { + const value = safeField(record, key); + if (typeof value === 'string' && value) + out.push(value.toLowerCase()); + } + }; + const record = providerRecord(payload); + fromRecord(record); + if (!record) + return; + fromRecord(providerRecord(safeField(record, 'metadata'))); + const error = providerRecord(safeField(record, 'error')); + fromRecord(error); + fromRecord(error ? providerRecord(safeField(error, 'metadata')) : undefined); + const response = providerRecord(safeField(record, 'response')); + fromRecord(response); + const responseError = response ? providerRecord(safeField(response, 'error')) : undefined; + fromRecord(responseError); + fromRecord(responseError ? providerRecord(safeField(responseError, 'metadata')) : undefined); +} +function normalizeProviderError(error) { + const target = providerErrorTarget(error); + if (target instanceof Error) { + const responseHeaders = responseHeadersFromError(target); + const code = 'code' in target ? String(target.code) : ''; + const statusCode = 'statusCode' in target + ? String(target.statusCode) + : 'status' in target + ? String(target.status) + : ''; + const rawBody = target.responseBody; + const body = typeof rawBody === 'string' ? rawBody : ''; + const structuredCodes = []; + collectStructuredCodes(target, structuredCodes); + collectStructuredCodes(target.data, structuredCodes); + if (body) { + // The failed-response handler keeps the raw body even when the provider + // JSON failed the schema (which is exactly when `data` is absent). + try { + collectStructuredCodes(JSON.parse(body), structuredCodes); + } + catch { + // Not JSON — no structured evidence. + } + } + return { + target, + // The raw body joins the text evidence: when the provider JSON fails + // the error schema, `message` degrades to the statusText and the body + // is the ONLY carrier of the provider's wording (e.g. an + // OpenAI-compatible `{error: string}` overflow). Positives and vetoes + // both run over the same full text. + evidence: { + text: `${target.name} ${code} ${statusCode} ${target.message}${body ? ` ${body}` : ''}`.toLowerCase(), + statusCode, + code, + structuredCodes, + }, + summarySources: providerFailureSources(target), + ...(responseHeaders ? { responseHeaders } : {}), + }; + } + if (typeof target === 'string') { + const parsed = parsedProviderValue(target); + const structuredCodes = []; + if (parsed !== undefined) + collectStructuredCodes(parsed, structuredCodes); + return { + target, + evidence: { text: target.toLowerCase(), statusCode: '', code: '', structuredCodes }, + summarySources: providerFailureSources(parsed), + ...(parsed === undefined + ? { bareMessage: target } + : typeof parsed === 'string' + ? { bareMessage: parsed } + : {}), + }; + } + if (typeof target === 'object' && target !== null) { + const record = target; + const responseHeaders = responseHeadersFromError(target); + const field = (key) => { + const value = record[key]; + return typeof value === 'string' || typeof value === 'number' ? String(value) : ''; + }; + const structuredCodes = []; + collectStructuredCodes(record, structuredCodes); + const rawBody = safeField(record, 'responseBody'); + if (typeof rawBody === 'string') { + const parsedBody = parsedProviderValue(rawBody); + if (parsedBody !== undefined) + collectStructuredCodes(parsedBody, structuredCodes); + } + let text; + try { + // Serialize the whole value so message/code text is evidence no matter + // which of the known provider shapes carried it. + text = JSON.stringify(target).toLowerCase(); + } + catch { + text = String(target).toLowerCase(); + } + return { + target, + evidence: { + text, + statusCode: field('statusCode') || field('status'), + code: field('code'), + structuredCodes, + }, + summarySources: providerFailureSources(target), + ...(responseHeaders ? { responseHeaders } : {}), + }; + } + return undefined; +} +/** + * Retains only allowlisted provider failure fields. The provider value may also + * contain request bodies, headers, or credentials, so it must never be copied + * or serialized as diagnostic output wholesale. + */ +export function providerFailureSummary(error) { + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return undefined; + const summary = providerFailureSummaryFromFacts(facts); + if (!summary) + return undefined; + return { + message: summary.message, + ...(summary.code !== undefined ? { code: summary.code } : {}), + }; +} +function providerFailureSummaryFromFacts(facts) { + const link = facts.messageLink; + const sources = link ? link.summarySources : facts.summarySources; + const message = link ? firstProviderMessage(link) : undefined; + const code = strongestProviderCode(link ?? facts); + const statusCode = firstProviderField(sources, ['statusCode', 'status']); + const requestId = firstProviderField(sources, ['requestId', 'request_id']) ?? + boundedProviderField(facts.responseHeaders?.['x-request-id']); + const metadata = [ + ...(code ? [`code=${code}`] : []), + ...(statusCode && statusCode !== code ? [`status=${statusCode}`] : []), + ...(requestId ? [`requestId=${requestId}`] : []), + ]; + if (!message && metadata.length === 0) + return undefined; + const suffix = metadata.length > 0 ? ` (${metadata.join(', ')})` : ''; + const messageBudget = Math.max(1, PROVIDER_FAILURE_SUMMARY_MAX_BYTES - Buffer.byteLength(suffix, 'utf8')); + const summary = `${truncateUtf8(redactSecrets(message ?? 'Provider request failed'), messageBudget, '…')}${suffix}`; + return { + message: truncateUtf8(summary, PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'), + ...(code || statusCode ? { code: code ?? statusCode } : {}), + boundedProviderMessage: message !== undefined && hasProviderMessageSource(facts), + }; +} +/** + * Projects provider errors into a small durable fingerprint. Unlike the + * presentation summary, this intentionally excludes provider messages and + * response bodies: even redacted free text can echo prompts or credentials. + */ +export function providerFailureDiagnostic(error) { + const failure = providerFailureResult(error); + return { + errorClass: failure.errorClass, + ...(failure.httpStatus !== undefined ? { httpStatus: failure.httpStatus } : {}), + ...(failure.providerCode !== undefined ? { providerCode: failure.providerCode } : {}), + ...(failure.providerRequestId !== undefined + ? { providerRequestId: failure.providerRequestId } + : {}), + retryable: failure.retryable, + }; +} +/** The single structured provider-failure authority for Runtime consumers. */ +export function providerFailureResult(error) { + if (RetryError.isInstance(error) && error.reason === 'abort') { + return { errorClass: 'Abort', retryable: false }; + } + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return { errorClass: 'Other', retryable: false }; + const sources = facts.summarySources; + const rawStatus = facts.evidence.statusCode || firstProviderField(sources, ['statusCode', 'status']); + const numericStatus = Number(rawStatus); + const httpStatus = Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 + ? numericStatus + : undefined; + const classified = normalizedClassifiedClass(facts); + const errorClass = normalizedProviderFailureClass(classified, httpStatus); + // When a provider message is projected, its code must come from the same + // chain link — never an outer link's code paired with an inner link's + // message (#2521). + const providerCode = strongestProviderCode(facts.messageLink ?? facts); + const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? + boundedProviderField(facts.responseHeaders?.['x-request-id']); + const retry = providerRetryMetadataFromFacts(facts); + const summary = providerFailureSummaryFromFacts(facts); + return { + errorClass, + ...(httpStatus !== undefined ? { httpStatus } : {}), + ...(providerCode !== undefined ? { providerCode } : {}), + ...(providerRequestId !== undefined ? { providerRequestId } : {}), + retryable: retry.retryable, + ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), + ...(summary?.boundedProviderMessage === true + ? { + message: summary.message, + boundedProviderMessage: true, + } + : {}), + }; +} +function strongestProviderCode(facts) { + const semantic = facts.evidence.structuredCodes.find((value) => PROVIDER_AUTH_CODES.has(value) || + PROVIDER_BILLING_CODES.has(value) || + PROVIDER_PERMISSION_CODES.has(value) || + PROVIDER_USAGE_LIMIT_CODES.has(value) || + PROVIDER_RATE_LIMIT_CODES.has(value) || + PROVIDER_UNAVAILABLE_CODES.has(value) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(value)); + return (boundedProviderField(semantic) ?? + firstProviderField(facts.summarySources, ['code']) ?? + firstProviderField(facts.summarySources, ['type'])); +} +function normalizedProviderFailureClass(classified, httpStatus) { + // The semantic class already includes structured provider identifiers and + // therefore outranks the transport status used only as a fallback. + if (isProviderFailureClass(classified) && classified !== 'Other') + return classified; + if (httpStatus === 401) + return 'Auth'; + if (httpStatus === 402) + return 'ProviderBilling'; + if (httpStatus === 408) + return 'Timeout'; + if (httpStatus === 413) + return 'ContextLength'; + if (httpStatus === 429) + return 'RateLimit'; + if (httpStatus !== undefined && httpStatus >= 400 && httpStatus <= 499) { + return 'RequestRejected'; + } + if (httpStatus !== undefined && httpStatus >= 500 && httpStatus <= 599) { + return 'ProviderUnavailable'; + } + return 'Other'; +} +function providerFailureDiagnosticFacts(error) { + let current = providerErrorTarget(error); + const chain = []; + const seen = new Set(); + for (let depth = 0; depth < 4 && current !== undefined && !seen.has(current); depth += 1) { + seen.add(current); + const facts = normalizeProviderError(current); + if (facts) + chain.push(facts); + current = + current && typeof current === 'object' + ? safeField(current, 'cause') + : undefined; + } + if (chain.length === 0) + return undefined; + // Provider semantics can sit below an SDK/transport wrapper that also owns + // the HTTP status. Aggregate the bounded cause chain instead of letting the + // first status-bearing object discard stronger structured codes. Provider + // sources are ordered inner-first for message/code projection; transport + // status and retry hints remain available as fallback evidence. + const providerFirst = [...chain].reverse(); + const messageFacts = providerFirst.filter((facts) => hasProviderMessageSource(facts)); + // The message and its paired code/status must come from the SAME chain + // link: an inner link's transport message stamped with an outer link's + // provider code presents a sentence the provider never said (#2521). + const messageLink = messageFacts.find((facts) => firstProviderMessage(facts) !== undefined); + const responseHeaders = Object.assign({}, ...chain.map((facts) => facts.responseHeaders ?? {})); + const bareMessage = messageFacts.find((facts) => facts.bareMessage)?.bareMessage; + return { + target: chain[0].target, + ...(messageLink ? { messageLink } : {}), + evidence: { + text: chain.map((facts) => facts.evidence.text).join(' '), + statusCode: chain.find((facts) => facts.evidence.statusCode)?.evidence.statusCode ?? '', + code: chain.find((facts) => facts.evidence.code)?.evidence.code ?? '', + structuredCodes: [...new Set(chain.flatMap((facts) => facts.evidence.structuredCodes))], + }, + summarySources: { + records: providerFirst.flatMap((facts) => facts.summarySources.records), + stringErrors: providerFirst.flatMap((facts) => facts.summarySources.stringErrors), + }, + messageSources: { + records: messageFacts.flatMap((facts) => facts.summarySources.records), + stringErrors: messageFacts.flatMap((facts) => facts.summarySources.stringErrors), + }, + boundedProviderMessageSource: messageFacts.length > 0, + ...(bareMessage ? { bareMessage } : {}), + ...(responseHeaders && Object.keys(responseHeaders).length > 0 ? { responseHeaders } : {}), + }; +} +function providerFailureSources(error) { + const record = objectRecord(error); + if (!record) + return { records: [], stringErrors: [] }; + const data = objectRecord(safeField(record, 'data')); + const nestedError = providerRecord(safeField(record, 'error')); + const dataError = providerRecord(data ? safeField(data, 'error') : undefined); + const parsedBody = parsedProviderBody(safeField(record, 'responseBody')); + const parsedBodyError = providerRecord(parsedBody ? safeField(parsedBody, 'error') : undefined); + return { + records: [dataError, data, parsedBodyError, parsedBody, nestedError, record].filter((source) => source !== undefined), + stringErrors: [ + data ? safeField(data, 'error') : undefined, + parsedBody ? safeField(parsedBody, 'error') : undefined, + safeField(record, 'error'), + ], + }; +} +function providerRecord(value) { + return objectRecord(typeof value === 'string' ? (parsedProviderValue(value) ?? value) : value); +} +function firstProviderMessage(facts) { + if (facts.bareMessage !== undefined) + return boundedProviderMessage(facts.bareMessage); + const sources = facts.messageSources ?? facts.summarySources; + const candidates = [ + ...sources.records.map((source) => safeField(source, 'message')), + ...sources.stringErrors, + ]; + return candidates + .map((candidate) => boundedProviderMessage(candidate)) + .find((value) => value !== undefined); +} +function hasProviderMessageSource(facts) { + if (facts.boundedProviderMessageSource !== undefined) { + return facts.boundedProviderMessageSource; + } + // Positive provider provenance is required for every link shape: the link's + // own `.message` — Error or plain object alike — is internal text until a + // nested provider source (data/error/responseBody payloads) or a string + // error carries provider wording. A bare `{ message }` cause from our own + // code or a transport shim must not be certified as a bounded provider + // message (#2521). + const targetRecord = objectRecord(facts.target); + return (facts.summarySources.records.some((source) => source !== targetRecord && + boundedProviderMessage(safeField(source, 'message')) !== undefined) || + facts.summarySources.stringErrors.some((candidate) => boundedProviderMessage(candidate) !== undefined)); +} +function firstProviderField(sources, keys) { + for (const key of keys) { + for (const source of sources.records) { + const value = boundedProviderField(safeField(source, key)); + if (value !== undefined) + return value; + } + } + return undefined; +} +function parsedProviderBody(value) { + if (typeof value !== 'string') + return undefined; + return objectRecord(parsedProviderValue(value)); +} +function parsedProviderValue(value) { + try { + return JSON.parse(value); + } + catch { + return undefined; + } +} +function objectRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value + : undefined; +} +function safeField(record, key) { + try { + return record[key]; + } + catch { + return undefined; + } +} +function boundedProviderField(value) { + if (typeof value !== 'string' && typeof value !== 'number') + return undefined; + const normalized = String(value).trim(); + if (!normalized) + return undefined; + return truncateUtf8(redactSecrets(normalized), PROVIDER_FAILURE_FIELD_MAX_BYTES, '…'); +} +function boundedProviderMessage(value, parseJson = true) { + if (typeof value !== 'string') + return undefined; + let normalized = value.trim(); + if (!normalized) + return undefined; + if (parseJson) { + const parsed = parsedProviderValue(normalized); + if (parsed !== undefined) { + if (typeof parsed === 'string') { + normalized = parsed.trim(); + } + else { + const sources = providerFailureSources(parsed); + const candidates = [ + ...sources.records.map((source) => safeField(source, 'message')), + ...sources.stringErrors, + ]; + return candidates + .map((candidate) => boundedProviderMessage(candidate, false)) + .find((candidate) => candidate !== undefined); + } + } + } + if (!normalized) + return undefined; + return truncateUtf8(redactSecrets(normalized), PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'); +} +/** + * Provider context-length overflow signatures. A request-level 400/413 whose + * message matches one of these means the input exceeded the model's context + * window — the reactive-recovery trigger (issue #882 PR 2). The set is ported + * from pi's battle-tested table and covers the providers Maka ships in its + * registry (Anthropic, OpenAI/-compatible, Google, xAI, Groq, OpenRouter, + * Mistral, MiniMax, Kimi/Moonshot, Together, llama.cpp/LM Studio/Ollama, …). + * Matched against the ORIGINAL error's composite fields (name, code, status, + * message), never the generalized string. All of these are free-text evidence + * and can be vetoed by NON_CONTEXT_OVERFLOW_PATTERNS: a capacity statement or + * overflow phrase quoted inside a throttling/quota error must not trigger + * recovery — only a structured provider code is unconditional. + */ +const CONTEXT_OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic token overflow + /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions & Responses) + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))?/i, // OpenAI-compatible proxies (LiteLLM) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter (most backends) + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI + // GitHub Copilot: "prompt token count of X exceeds the limit of Y". The INPUT + // subject is required — a bare "token count of N exceeds the limit of M" also + // matches output/completion caps, and a bare "exceeds the limit of N" matches + // file-size and other quota errors; neither is fixable by history compaction. + /(?:prompt|input|context|message)[^.]{0,80}token count of [\d,]+ exceeds the limit of [\d,]+/i, + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding + /too large for model with \d+ maximum context length/i, // Mistral + /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server + /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text + /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /context[_ ]length[_ ]exceeded/i, // OpenAI structured error code (also generic) + // Ambiguous token-limit wording that is an input overflow only when an + // input-like word is the subject. `request` is deliberately NOT in the + // subject list: it appears in generic prefixes ("Invalid request: ...") + // without saying anything about which side of the token budget overflowed. + /(?:prompt|input|context|message)[^.]{0,80}too many tokens/i, + /(?:prompt|input|context|message)[^.]{0,80}token limit exceeded/i, +]; +/** + * Wording that looks token-shaped but is NOT an input overflow: throttling / + * quota / rate limiting, and complete OUTPUT-cap relations in every observed + * permutation of role word (output/completion/max_tokens) and token + * predicate — subject before predicate ("completion has too many tokens", + * "max_tokens token limit exceeded"), predicate before subject ("too many + * tokens were requested for the completion"), the count-of form ("output + * token count of N exceeds"), the role word embedded inside the phrase + * ("too many completion tokens were requested"), and the role-tokens-exceed + * form ("Maximum completion tokens exceeded"). Noun phrases alone (e.g. + * "completion token count") are not excluded: they also appear as usage + * breakdowns inside genuine input-overflow messages, and "(prompt + + * completion) exceed" combined-budget wording stays classifiable because the + * role word is not adjacent to "tokens". + */ +const NON_CONTEXT_OVERFLOW_PATTERNS = [ + /rate limit/i, + /too many requests/i, + /throttl/i, + /quota/i, + /(?:output|completion|max_tokens)\b[^.]{0,60}(?:too many tokens|token limit exceeded)/i, + /(?:too many tokens|token limit exceeded)[^.]{0,60}\b(?:output|completion|max_tokens)/i, + /(?:output|completion)\s+token\s+(?:count|limit)[^.]{0,40}exceed/i, + /too many (?:output|completion|max_tokens)[^.]{0,20}tokens/i, + /\b(?:output|completion|max_tokens)\s+tokens?\b[^.]{0,20}exceed/i, +]; +/** + * Two-layer overflow detection on an error's raw text (the composite of its + * original name/code/status/message). Triggering recovery requires positive + * evidence of an INPUT overflow — the one class history compaction can fix: + * 1. Vetoes first: throttling/quota wording and complete output-cap relations + * disqualify every free-text signal. Free text is never unconditional — a + * capacity statement quoted inside a throttle error is not an overflow. + * 2. Positive overflow relations count only when nothing vetoed. Structured + * provider codes (the unconditional evidence) are classifyError's job, + * checked before this text layer ever runs. + */ +export function isContextOverflowErrorText(text) { + if (!text) + return false; + if (NON_CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(text))) + return false; + return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(text)); +} +/** + * Classifies a provider error by DESCENDING evidence strength over the + * normalized evidence (Error, string, or plain stream-error-part object): + * abort wrapper → structured account state → the provider's structured + * overflow code → numeric status fallbacks (402, 429, 401; numeric fields, + * never substrings) → bare 413 + * (HTTP: request entity too large — itself input-side evidence, Cerebras sends it with no body) → + * vetoable free-text overflow relations → generic 5xx → free-text abort → weak word + * heuristics. Specific overflow evidence outranks a generic 5xx because + * proxies (LiteLLM) wrap provider overflows in 503s; the weak heuristics + * rank last so "generate" can never become a rate limit. Numeric status + * outranks free text because the text spans the whole chain, JSON key names + * included. + */ +export function classifyError(error) { + if (RetryError.isInstance(error) && error.reason === 'abort') + return 'Abort'; + const facts = providerFailureDiagnosticFacts(error); + return facts ? classifyProviderFacts(facts) : 'Other'; +} +function classifyProviderFacts(facts) { + const { target: classificationTarget, evidence } = facts; + const { text, statusCode, code, structuredCodes } = evidence; + const normalizedCode = code.toLowerCase(); + if (code === OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR) + return 'Network'; + // Structured capacity evidence outranks account-state codes: a gateway can + // surface capacity as a quota-shaped error, and the retry guidance differs. + if (PROVIDER_CAPACITY_CODES.has(normalizedCode) || + structuredCodes.some((c) => PROVIDER_CAPACITY_CODES.has(c))) { + return 'ProviderCapacity'; + } + if (structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) + return 'Auth'; + if (structuredCodes.some((value) => PROVIDER_BILLING_CODES.has(value))) + return 'ProviderBilling'; + if (structuredCodes.some((value) => PROVIDER_PERMISSION_CODES.has(value))) + return 'ProviderPermission'; + if (structuredCodes.some((value) => PROVIDER_USAGE_LIMIT_CODES.has(value))) + return 'UsageLimit'; + if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) + return 'RateLimit'; + if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) + return 'ProviderUnavailable'; + // The provider's structured context code is unconditional input-overflow + // evidence. It outranks outer transport status and message fallbacks. + if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) + return 'ContextLength'; + // Numeric status outranks free text: the text spans the whole cause chain + // including JSON key names, so a 429 whose body reads "request aborted: + // too many requests" must keep its rate-limit class and retry-after + // handling, and a 500 carrying an `aborted` key stays ProviderUnavailable + // (#2521). + if (statusCode === '402' || code === '402') + return 'ProviderBilling'; + if (statusCode === '429' || code === '429') + return 'RateLimit'; + if (statusCode === '401' || code === '401') + return 'Auth'; + // A bare 403 is intentionally unknown: providers use it for valid-key + // permission failures, guardrails, subscription limits, and occasionally + // authentication. The provider's bounded diagnostic remains available. + if (statusCode === '413' || code === '413') + return 'ContextLength'; + // Free-text overflow relations on the composite text, veto-first inside. + if (isContextOverflowErrorText(text)) + return 'ContextLength'; + if (structuredCodes.some((c) => PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(c))) + return 'ProviderUnavailable'; + if (/^5\d\d$/.test(statusCode) || /^5\d\d$/.test(code)) + return 'ProviderUnavailable'; + if (text.includes('abort')) + return 'Abort'; + // Weak word heuristics remain as compatibility fallbacks after all stronger + // provider facts. They must not override a structured account state. + if (/\brate\b|rate[_-]?limit/.test(text)) + return 'RateLimit'; + if (isAuthenticationErrorText(text)) + return 'Auth'; + if (text.includes('timeout')) + return 'Timeout'; + if (text.includes('network') || + text.includes('fetch') || + /\btypeerror\b.*\bterminated\b/.test(text)) + return 'Network'; + return classificationTarget instanceof Error ? classificationTarget.name || 'Other' : 'Other'; +} +export function errorPresentationFromClass(errorClass) { + switch (errorClass) { + case 'ContextLength': + return { reason: 'context_overflow', message: 'Context window exceeded' }; + case 'Timeout': + return { reason: 'timeout', message: 'Request timed out' }; + case 'Auth': + return { reason: 'auth', message: 'Authentication failed' }; + case 'ProviderBilling': + return { reason: 'provider_billing', message: 'Provider billing required' }; + case 'ProviderPermission': + return { reason: 'provider_permission', message: 'Provider access denied' }; + case 'ProviderUnavailable': + return { reason: 'provider_unavailable', message: 'Provider returned an error' }; + case 'RateLimit': + return { reason: 'rate_limit', message: 'Rate limit exceeded' }; + case 'UsageLimit': + return { reason: 'usage_limit', message: 'Usage limit reached' }; + case 'Network': + return { reason: 'network', message: 'Network error' }; + default: + return {}; + } +} diff --git a/packages/runtime/dist-iso2/provider-error-classification.js b/packages/runtime/dist-iso2/provider-error-classification.js new file mode 100644 index 0000000000..c93f200c77 --- /dev/null +++ b/packages/runtime/dist-iso2/provider-error-classification.js @@ -0,0 +1,856 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { RetryError } from 'ai'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { isProviderFailureClass } from '@maka/core/provider-failure'; +import { isAuthenticationErrorText, redactSecrets } from '@maka/core/redaction'; +/** + * Structured provider error identifiers that mean the INPUT exceeded the + * model's context window. These come from the provider's error JSON and are + * the ONLY unconditional overflow evidence: free-text signals are vetoable. + */ +const CONTEXT_OVERFLOW_PROVIDER_CODES = new Set([ + 'context_length_exceeded', // OpenAI & OpenAI-compatible: error.code + 'model_context_window_exceeded', // z.ai: error.code + 'request_too_large', // Anthropic byte-size overflow (HTTP 413): error.type +]); +/** Stable account-state meanings exposed by provider-owned structured fields. */ +const PROVIDER_AUTH_CODES = new Set([ + 'authentication', + 'authentication_error', + 'invalid_api_key', +]); +const PROVIDER_BILLING_CODES = new Set([ + 'insufficient_quota', + 'payment_required', +]); +const PROVIDER_PERMISSION_CODES = new Set(['permission_denied']); +const PROVIDER_USAGE_LIMIT_CODES = new Set(['usage_limit_reached']); +const PROVIDER_RATE_LIMIT_CODES = new Set([ + 'rate_limit_error', + 'rate_limit_exceeded', + 'rate_limited', +]); +const PROVIDER_UNAVAILABLE_PROVIDER_CODES = new Set([ + 'server_error', // OpenAI-compatible stream errors can omit the HTTP status. +]); +const PROVIDER_UNAVAILABLE_CODES = new Set([ + 'overloaded_error', + 'provider_overloaded', + 'provider_unavailable', +]); +// xAI emits this code for transient model capacity failures, including when +// the same payload is relayed through an OpenAI-compatible gateway. Do not +// add the generic gRPC/Google `resource_exhausted` spelling here: that code +// represents quota exhaustion and needs different user guidance. +const PROVIDER_CAPACITY_CODES = new Set(['resource-exhausted']); +/** + * Closed vocabulary check for provider codes that may feed Maka's + * terminal-state taxonomy (`ErrorEvent.code` → `failureClass`). A provider's + * free-form code string is display metadata; an arbitrary token such as + * `tool_choice_invalid` must never steer the Host-owned taxonomy or the + * Desktop label/recovery matching that reads it (#2521). + */ +export function taxonomySafeProviderCode(code) { + if (code === undefined) + return undefined; + const lower = code.toLowerCase(); + if ( + // Maka-owned sentinels are deterministic, not provider free-form wording. + RUNTIME_RETRYABLE_ERROR_CODES.has(code) || + // ContinuationReplayEmptyError.code (ai-sdk-backend): a Maka-owned class. + lower === 'continuation_replay_empty' || + // Recognized provider outage codes are closed-vocabulary evidence, not + // free-form tokens (e.g. OpenAI-compatible stream errors omit the status). + PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(lower) || + PROVIDER_AUTH_CODES.has(lower) || + PROVIDER_BILLING_CODES.has(lower) || + PROVIDER_PERMISSION_CODES.has(lower) || + PROVIDER_USAGE_LIMIT_CODES.has(lower) || + PROVIDER_RATE_LIMIT_CODES.has(lower) || + PROVIDER_UNAVAILABLE_CODES.has(lower) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(lower) || + /^[1-5]\d{2}$/.test(lower)) { + return code; + } + return undefined; +} +const PROVIDER_FAILURE_SUMMARY_MAX_BYTES = 2 * 1024; +const PROVIDER_FAILURE_FIELD_MAX_BYTES = 256; +const MAX_SAFE_TIMER_DELAY_MS = 2_147_483_647; +const OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR = 'OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR'; +const RUNTIME_RETRYABLE_ERROR_CODES = new Set([ + OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR, + 'OPENAI_RESPONSES_CONTINUATION_UNAVAILABLE', +]); +function providerErrorTarget(error) { + return RetryError.isInstance(error) && error.lastError !== undefined && error.lastError !== error + ? error.lastError + : error; +} +function responseHeadersFromError(error) { + if (typeof error !== 'object' || error === null) + return undefined; + const value = error.responseHeaders; + if (typeof value !== 'object' || value === null) + return undefined; + const headers = {}; + for (const [key, header] of Object.entries(value)) { + if (typeof header === 'string') + headers[key.toLowerCase()] = header; + } + return headers; +} +function parseRetryAfterMs(headers) { + const rawMilliseconds = headers['retry-after-ms']; + const rawRetryAfter = headers['retry-after']; + if (rawMilliseconds === undefined && rawRetryAfter === undefined) + return undefined; + let delayMs; + if (rawMilliseconds !== undefined) { + delayMs = Number(rawMilliseconds); + } + else { + const seconds = Number(rawRetryAfter); + delayMs = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(rawRetryAfter) - Date.now(); + } + if (!Number.isFinite(delayMs) || delayMs <= 0 || delayMs > MAX_SAFE_TIMER_DELAY_MS) + return null; + return Math.ceil(delayMs); +} +/** + * Normalizes provider retry facts without leaking SDK error objects or raw + * response headers across the ModelAdapter boundary. + */ +export function providerRetryMetadata(error) { + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return { retryable: false }; + return providerRetryMetadataFromFacts(facts); +} +/** + * The classification that retry and failure projections must agree on: a + * weak text-derived `Auth` class is demoted when the envelope's own numeric + * status contradicts it (an "authentication" wording behind a 500 is a + * provider-unavailable event, not an account state), so the Runtime retry + * owner never skips a transient 5xx because of the original wording. + */ +function normalizedClassifiedClass(facts) { + const classified = classifyProviderFacts(facts); + if (classified === 'Auth' && + !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) { + const rawStatus = facts.evidence.statusCode || + firstProviderField(facts.summarySources, ['statusCode', 'status']); + const numericStatus = Number(rawStatus); + const httpStatus = Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 + ? numericStatus + : undefined; + if (httpStatus !== undefined && httpStatus !== 401) + return 'Other'; + } + return classified; +} +function providerRetryMetadataFromFacts(facts) { + const { evidence } = facts; + if (RUNTIME_RETRYABLE_ERROR_CODES.has(evidence.code)) + return { retryable: true }; + // Same numeric-status resolution as the failure projection, so a nested + // envelope status participates in the retry decision identically. + const rawStatus = evidence.statusCode || + evidence.code || + firstProviderField(facts.summarySources, ['statusCode', 'status']); + const status = Number(rawStatus); + const errorClass = normalizedClassifiedClass(facts); + // Account state cannot be repaired by immediately repeating the same + // physical request, even when a provider reports it through HTTP 429. An + // Abort is by definition not repairable by repeating the request either — + // the RetryError early return already says so, and a text-derived Abort + // must not fall through to the 5xx retryable rule with the opposite answer. + if (errorClass === 'Abort' || + errorClass === 'Auth' || + errorClass === 'ProviderBilling' || + errorClass === 'ProviderPermission' || + errorClass === 'UsageLimit' || + errorClass === 'ContextLength') { + return { retryable: false }; + } + const retryAfterMs = parseRetryAfterMs(facts.responseHeaders ?? {}); + if (errorClass === 'ProviderCapacity') { + // Capacity is transient even when the provider sends a malformed delay; + // fall back to the adapter's bounded local backoff in that case. + return { + retryable: true, + ...(retryAfterMs !== undefined && retryAfterMs !== null ? { retryAfterMs } : {}), + }; + } + if (errorClass === 'RateLimit' || status === 429) { + if (retryAfterMs === undefined || retryAfterMs === null) + return { retryable: false }; + return { retryable: true, retryAfterMs }; + } + const retryable = errorClass === 'Network' || + errorClass === 'ProviderUnavailable' || + status === 408 || + status === 409 || + (status >= 500 && status <= 599); + if (!retryable) + return { retryable: false }; + if (retryAfterMs === null) + return { retryable: false }; + return { + retryable: true, + ...(retryAfterMs !== undefined ? { retryAfterMs } : {}), + }; +} +/** Collects stable identifiers from the provider envelopes Maka receives. */ +function collectStructuredCodes(payload, out) { + const fromRecord = (record) => { + if (!record) + return; + for (const key of ['code', 'type', 'error_type', 'provider_code']) { + const value = safeField(record, key); + if (typeof value === 'string' && value) + out.push(value.toLowerCase()); + } + }; + const record = providerRecord(payload); + fromRecord(record); + if (!record) + return; + fromRecord(providerRecord(safeField(record, 'metadata'))); + const error = providerRecord(safeField(record, 'error')); + fromRecord(error); + fromRecord(error ? providerRecord(safeField(error, 'metadata')) : undefined); + const response = providerRecord(safeField(record, 'response')); + fromRecord(response); + const responseError = response ? providerRecord(safeField(response, 'error')) : undefined; + fromRecord(responseError); + fromRecord(responseError ? providerRecord(safeField(responseError, 'metadata')) : undefined); +} +function normalizeProviderError(error) { + const target = providerErrorTarget(error); + if (target instanceof Error) { + const responseHeaders = responseHeadersFromError(target); + const code = 'code' in target ? String(target.code) : ''; + const statusCode = 'statusCode' in target + ? String(target.statusCode) + : 'status' in target + ? String(target.status) + : ''; + const rawBody = target.responseBody; + const body = typeof rawBody === 'string' ? rawBody : ''; + const structuredCodes = []; + collectStructuredCodes(target, structuredCodes); + collectStructuredCodes(target.data, structuredCodes); + if (body) { + // The failed-response handler keeps the raw body even when the provider + // JSON failed the schema (which is exactly when `data` is absent). + try { + collectStructuredCodes(JSON.parse(body), structuredCodes); + } + catch { + // Not JSON — no structured evidence. + } + } + return { + target, + // The raw body joins the text evidence: when the provider JSON fails + // the error schema, `message` degrades to the statusText and the body + // is the ONLY carrier of the provider's wording (e.g. an + // OpenAI-compatible `{error: string}` overflow). Positives and vetoes + // both run over the same full text. + evidence: { + text: `${target.name} ${code} ${statusCode} ${target.message}${body ? ` ${body}` : ''}`.toLowerCase(), + statusCode, + code, + structuredCodes, + }, + summarySources: providerFailureSources(target), + ...(responseHeaders ? { responseHeaders } : {}), + }; + } + if (typeof target === 'string') { + const parsed = parsedProviderValue(target); + const structuredCodes = []; + if (parsed !== undefined) + collectStructuredCodes(parsed, structuredCodes); + return { + target, + evidence: { text: target.toLowerCase(), statusCode: '', code: '', structuredCodes }, + summarySources: providerFailureSources(parsed), + ...(parsed === undefined + ? { bareMessage: target } + : typeof parsed === 'string' + ? { bareMessage: parsed } + : {}), + }; + } + if (typeof target === 'object' && target !== null) { + const record = target; + const responseHeaders = responseHeadersFromError(target); + const field = (key) => { + const value = record[key]; + return typeof value === 'string' || typeof value === 'number' ? String(value) : ''; + }; + const structuredCodes = []; + collectStructuredCodes(record, structuredCodes); + const rawBody = safeField(record, 'responseBody'); + if (typeof rawBody === 'string') { + const parsedBody = parsedProviderValue(rawBody); + if (parsedBody !== undefined) + collectStructuredCodes(parsedBody, structuredCodes); + } + let text; + try { + // Serialize the whole value so message/code text is evidence no matter + // which of the known provider shapes carried it. + text = JSON.stringify(target).toLowerCase(); + } + catch { + text = String(target).toLowerCase(); + } + // Classification evidence must see the same nested envelope the failure + // projection reads: a JSON-shaped error carries its code/status inside + // `data.error`, not on the target itself. Without this merge, structured + // account-state and capacity codes hide behind a transport status and the + // weak word heuristics misread them (#2521). + const sources = providerFailureSources(target); + const nestedStatusCode = firstProviderField(sources, ['statusCode', 'status']); + const nestedCode = firstProviderField(sources, ['code']); + const nestedStructuredCodes = []; + for (const record of sources.records) + collectStructuredCodes(record, nestedStructuredCodes); + return { + target, + evidence: { + text, + statusCode: field('statusCode') || field('status') || nestedStatusCode, + code: field('code') || nestedCode, + structuredCodes: [...new Set([...structuredCodes, ...nestedStructuredCodes])], + }, + summarySources: sources, + ...(responseHeaders ? { responseHeaders } : {}), + }; + } + return undefined; +} +/** + * Retains only allowlisted provider failure fields. The provider value may also + * contain request bodies, headers, or credentials, so it must never be copied + * or serialized as diagnostic output wholesale. + */ +export function providerFailureSummary(error) { + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return undefined; + const summary = providerFailureSummaryFromFacts(facts); + if (!summary) + return undefined; + return { + message: summary.message, + ...(summary.code !== undefined ? { code: summary.code } : {}), + }; +} +function providerFailureSummaryFromFacts(facts) { + const link = facts.messageLink; + const sources = link ? link.summarySources : facts.summarySources; + const message = link ? firstProviderMessage(link) : undefined; + const code = strongestProviderCode(link ?? facts); + const statusCode = firstProviderField(sources, ['statusCode', 'status']); + const requestId = firstProviderField(sources, ['requestId', 'request_id']) ?? + boundedProviderField(facts.responseHeaders?.['x-request-id']); + const metadata = [ + ...(code ? [`code=${code}`] : []), + ...(statusCode && statusCode !== code ? [`status=${statusCode}`] : []), + ...(requestId ? [`requestId=${requestId}`] : []), + ]; + if (!message && metadata.length === 0) + return undefined; + const suffix = metadata.length > 0 ? ` (${metadata.join(', ')})` : ''; + const messageBudget = Math.max(1, PROVIDER_FAILURE_SUMMARY_MAX_BYTES - Buffer.byteLength(suffix, 'utf8')); + const summary = `${truncateUtf8(redactSecrets(message ?? 'Provider request failed'), messageBudget, '…')}${suffix}`; + return { + message: truncateUtf8(summary, PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'), + ...(code || statusCode ? { code: code ?? statusCode } : {}), + boundedProviderMessage: message !== undefined && hasProviderMessageSource(facts), + }; +} +/** + * Projects provider errors into a small durable fingerprint. Unlike the + * presentation summary, this intentionally excludes provider messages and + * response bodies: even redacted free text can echo prompts or credentials. + */ +export function providerFailureDiagnostic(error) { + const failure = providerFailureResult(error); + return { + errorClass: failure.errorClass, + ...(failure.httpStatus !== undefined ? { httpStatus: failure.httpStatus } : {}), + ...(failure.providerCode !== undefined ? { providerCode: failure.providerCode } : {}), + ...(failure.providerRequestId !== undefined + ? { providerRequestId: failure.providerRequestId } + : {}), + retryable: failure.retryable, + }; +} +/** The single structured provider-failure authority for Runtime consumers. */ +export function providerFailureResult(error) { + if (RetryError.isInstance(error) && error.reason === 'abort') { + return { errorClass: 'Abort', retryable: false }; + } + const facts = providerFailureDiagnosticFacts(error); + if (!facts) + return { errorClass: 'Other', retryable: false }; + const sources = facts.summarySources; + const rawStatus = facts.evidence.statusCode || firstProviderField(sources, ['statusCode', 'status']); + const numericStatus = Number(rawStatus); + const httpStatus = Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 + ? numericStatus + : undefined; + const classified = normalizedClassifiedClass(facts); + const errorClass = normalizedProviderFailureClass(classified, httpStatus); + // When a provider message is projected, its code must come from the same + // chain link — never an outer link's code paired with an inner link's + // message (#2521). + const providerCode = strongestProviderCode(facts.messageLink ?? facts); + const providerRequestId = firstProviderField(sources, ['requestId', 'request_id']) ?? + boundedProviderField(facts.responseHeaders?.['x-request-id']); + const retry = providerRetryMetadataFromFacts(facts); + const summary = providerFailureSummaryFromFacts(facts); + return { + errorClass, + ...(httpStatus !== undefined ? { httpStatus } : {}), + ...(providerCode !== undefined ? { providerCode } : {}), + ...(providerRequestId !== undefined ? { providerRequestId } : {}), + retryable: retry.retryable, + ...(retry.retryAfterMs !== undefined ? { retryAfterMs: retry.retryAfterMs } : {}), + ...(summary?.boundedProviderMessage === true + ? { + message: summary.message, + boundedProviderMessage: true, + } + : {}), + }; +} +function strongestProviderCode(facts) { + const semantic = facts.evidence.structuredCodes.find((value) => PROVIDER_AUTH_CODES.has(value) || + PROVIDER_BILLING_CODES.has(value) || + PROVIDER_PERMISSION_CODES.has(value) || + PROVIDER_USAGE_LIMIT_CODES.has(value) || + PROVIDER_RATE_LIMIT_CODES.has(value) || + PROVIDER_UNAVAILABLE_CODES.has(value) || + CONTEXT_OVERFLOW_PROVIDER_CODES.has(value)); + return (boundedProviderField(semantic) ?? + firstProviderField(facts.summarySources, ['code']) ?? + firstProviderField(facts.summarySources, ['type'])); +} +function normalizedProviderFailureClass(classified, httpStatus) { + // The semantic class already includes structured provider identifiers and + // therefore outranks the transport status used only as a fallback. + if (isProviderFailureClass(classified) && classified !== 'Other') + return classified; + if (httpStatus === 401) + return 'Auth'; + if (httpStatus === 402) + return 'ProviderBilling'; + if (httpStatus === 408) + return 'Timeout'; + if (httpStatus === 413) + return 'ContextLength'; + if (httpStatus === 429) + return 'RateLimit'; + if (httpStatus !== undefined && httpStatus >= 400 && httpStatus <= 499) { + return 'RequestRejected'; + } + if (httpStatus !== undefined && httpStatus >= 500 && httpStatus <= 599) { + return 'ProviderUnavailable'; + } + return 'Other'; +} +function providerFailureDiagnosticFacts(error) { + let current = providerErrorTarget(error); + const chain = []; + const seen = new Set(); + for (let depth = 0; depth < 4 && current !== undefined && !seen.has(current); depth += 1) { + seen.add(current); + const facts = normalizeProviderError(current); + if (facts) + chain.push(facts); + current = + current && typeof current === 'object' + ? safeField(current, 'cause') + : undefined; + } + if (chain.length === 0) + return undefined; + // Provider semantics can sit below an SDK/transport wrapper that also owns + // the HTTP status. Aggregate the bounded cause chain instead of letting the + // first status-bearing object discard stronger structured codes. Provider + // sources are ordered inner-first for message/code projection; transport + // status and retry hints remain available as fallback evidence. + const providerFirst = [...chain].reverse(); + const messageFacts = providerFirst.filter((facts) => hasProviderMessageSource(facts)); + // The message and its paired code/status must come from the SAME chain + // link: an inner link's transport message stamped with an outer link's + // provider code presents a sentence the provider never said (#2521). + const messageLink = messageFacts.find((facts) => firstProviderMessage(facts) !== undefined); + const responseHeaders = Object.assign({}, ...chain.map((facts) => facts.responseHeaders ?? {})); + const bareMessage = messageFacts.find((facts) => facts.bareMessage)?.bareMessage; + return { + target: chain[0].target, + ...(messageLink ? { messageLink } : {}), + evidence: { + text: chain.map((facts) => facts.evidence.text).join(' '), + statusCode: chain.find((facts) => facts.evidence.statusCode)?.evidence.statusCode ?? '', + code: chain.find((facts) => facts.evidence.code)?.evidence.code ?? '', + structuredCodes: [...new Set(chain.flatMap((facts) => facts.evidence.structuredCodes))], + }, + summarySources: { + records: providerFirst.flatMap((facts) => facts.summarySources.records), + stringErrors: providerFirst.flatMap((facts) => facts.summarySources.stringErrors), + }, + messageSources: { + records: messageFacts.flatMap((facts) => facts.summarySources.records), + stringErrors: messageFacts.flatMap((facts) => facts.summarySources.stringErrors), + }, + boundedProviderMessageSource: messageFacts.length > 0, + ...(bareMessage ? { bareMessage } : {}), + ...(responseHeaders && Object.keys(responseHeaders).length > 0 ? { responseHeaders } : {}), + }; +} +function providerFailureSources(error) { + const record = objectRecord(error); + if (!record) + return { records: [], stringErrors: [] }; + const data = objectRecord(safeField(record, 'data')); + const nestedError = providerRecord(safeField(record, 'error')); + const dataError = providerRecord(data ? safeField(data, 'error') : undefined); + const parsedBody = parsedProviderBody(safeField(record, 'responseBody')); + const parsedBodyError = providerRecord(parsedBody ? safeField(parsedBody, 'error') : undefined); + return { + records: [dataError, data, parsedBodyError, parsedBody, nestedError, record].filter((source) => source !== undefined), + stringErrors: [ + data ? safeField(data, 'error') : undefined, + parsedBody ? safeField(parsedBody, 'error') : undefined, + safeField(record, 'error'), + ], + }; +} +function providerRecord(value) { + return objectRecord(typeof value === 'string' ? (parsedProviderValue(value) ?? value) : value); +} +function firstProviderMessage(facts) { + if (facts.bareMessage !== undefined) + return boundedProviderMessage(facts.bareMessage); + const sources = facts.messageSources ?? facts.summarySources; + const candidates = [ + ...sources.records.map((source) => safeField(source, 'message')), + ...sources.stringErrors, + ]; + return candidates + .map((candidate) => boundedProviderMessage(candidate)) + .find((value) => value !== undefined); +} +function hasProviderMessageSource(facts) { + if (facts.boundedProviderMessageSource !== undefined) { + return facts.boundedProviderMessageSource; + } + // Positive provider provenance is required for every link shape: the link's + // own `.message` — Error or plain object alike — is internal text until a + // nested provider source (data/error/responseBody payloads) or a string + // error carries provider wording. A bare `{ message }` cause from our own + // code or a transport shim must not be certified as a bounded provider + // message (#2521). + const targetRecord = objectRecord(facts.target); + return (facts.summarySources.records.some((source) => source !== targetRecord && + boundedProviderMessage(safeField(source, 'message')) !== undefined) || + facts.summarySources.stringErrors.some((candidate) => boundedProviderMessage(candidate) !== undefined)); +} +function firstProviderField(sources, keys) { + for (const key of keys) { + for (const source of sources.records) { + const value = boundedProviderField(safeField(source, key)); + if (value !== undefined) + return value; + } + } + return undefined; +} +function parsedProviderBody(value) { + if (typeof value !== 'string') + return undefined; + return objectRecord(parsedProviderValue(value)); +} +function parsedProviderValue(value) { + try { + return JSON.parse(value); + } + catch { + return undefined; + } +} +function objectRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value + : undefined; +} +function safeField(record, key) { + try { + return record[key]; + } + catch { + return undefined; + } +} +function boundedProviderField(value) { + if (typeof value !== 'string' && typeof value !== 'number') + return undefined; + const normalized = String(value).trim(); + if (!normalized) + return undefined; + return truncateUtf8(redactSecrets(normalized), PROVIDER_FAILURE_FIELD_MAX_BYTES, '…'); +} +function boundedProviderMessage(value, parseJson = true) { + if (typeof value !== 'string') + return undefined; + let normalized = value.trim(); + if (!normalized) + return undefined; + if (parseJson) { + const parsed = parsedProviderValue(normalized); + if (parsed !== undefined) { + if (typeof parsed === 'string') { + normalized = parsed.trim(); + } + else { + const sources = providerFailureSources(parsed); + const candidates = [ + ...sources.records.map((source) => safeField(source, 'message')), + ...sources.stringErrors, + ]; + return candidates + .map((candidate) => boundedProviderMessage(candidate, false)) + .find((candidate) => candidate !== undefined); + } + } + } + if (!normalized) + return undefined; + return truncateUtf8(redactSecrets(normalized), PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'); +} +/** + * Provider context-length overflow signatures. A request-level 400/413 whose + * message matches one of these means the input exceeded the model's context + * window — the reactive-recovery trigger (issue #882 PR 2). The set is ported + * from pi's battle-tested table and covers the providers Maka ships in its + * registry (Anthropic, OpenAI/-compatible, Google, xAI, Groq, OpenRouter, + * Mistral, MiniMax, Kimi/Moonshot, Together, llama.cpp/LM Studio/Ollama, …). + * Matched against the ORIGINAL error's composite fields (name, code, status, + * message), never the generalized string. All of these are free-text evidence + * and can be vetoed by NON_CONTEXT_OVERFLOW_PATTERNS: a capacity statement or + * overflow phrase quoted inside a throttling/quota error must not trigger + * recovery — only a structured provider code is unconditional. + */ +const CONTEXT_OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic token overflow + /request_too_large/i, // Anthropic request byte-size overflow (HTTP 413) + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions & Responses) + /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))?/i, // OpenAI-compatible proxies (LiteLLM) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter (most backends) + /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter/Poolside + /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI + // GitHub Copilot: "prompt token count of X exceeds the limit of Y". The INPUT + // subject is required — a bare "token count of N exceeds the limit of M" also + // matches output/completion caps, and a bare "exceeds the limit of N" matches + // file-size and other quota errors; neither is fixable by history compaction. + /(?:prompt|input|context|message)[^.]{0,80}token count of [\d,]+ exceeds the limit of [\d,]+/i, + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding + /too large for model with \d+ maximum context length/i, // Mistral + /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server + /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text + /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /context[_ ]length[_ ]exceeded/i, // OpenAI structured error code (also generic) + // Ambiguous token-limit wording that is an input overflow only when an + // input-like word is the subject. `request` is deliberately NOT in the + // subject list: it appears in generic prefixes ("Invalid request: ...") + // without saying anything about which side of the token budget overflowed. + /(?:prompt|input|context|message)[^.]{0,80}too many tokens/i, + /(?:prompt|input|context|message)[^.]{0,80}token limit exceeded/i, +]; +/** + * Wording that looks token-shaped but is NOT an input overflow: throttling / + * quota / rate limiting, and complete OUTPUT-cap relations in every observed + * permutation of role word (output/completion/max_tokens) and token + * predicate — subject before predicate ("completion has too many tokens", + * "max_tokens token limit exceeded"), predicate before subject ("too many + * tokens were requested for the completion"), the count-of form ("output + * token count of N exceeds"), the role word embedded inside the phrase + * ("too many completion tokens were requested"), and the role-tokens-exceed + * form ("Maximum completion tokens exceeded"). Noun phrases alone (e.g. + * "completion token count") are not excluded: they also appear as usage + * breakdowns inside genuine input-overflow messages, and "(prompt + + * completion) exceed" combined-budget wording stays classifiable because the + * role word is not adjacent to "tokens". + */ +const NON_CONTEXT_OVERFLOW_PATTERNS = [ + /rate limit/i, + /too many requests/i, + /throttl/i, + /quota/i, + /(?:output|completion|max_tokens)\b[^.]{0,60}(?:too many tokens|token limit exceeded)/i, + /(?:too many tokens|token limit exceeded)[^.]{0,60}\b(?:output|completion|max_tokens)/i, + /(?:output|completion)\s+token\s+(?:count|limit)[^.]{0,40}exceed/i, + /too many (?:output|completion|max_tokens)[^.]{0,20}tokens/i, + /\b(?:output|completion|max_tokens)\s+tokens?\b[^.]{0,20}exceed/i, +]; +/** + * Two-layer overflow detection on an error's raw text (the composite of its + * original name/code/status/message). Triggering recovery requires positive + * evidence of an INPUT overflow — the one class history compaction can fix: + * 1. Vetoes first: throttling/quota wording and complete output-cap relations + * disqualify every free-text signal. Free text is never unconditional — a + * capacity statement quoted inside a throttle error is not an overflow. + * 2. Positive overflow relations count only when nothing vetoed. Structured + * provider codes (the unconditional evidence) are classifyError's job, + * checked before this text layer ever runs. + */ +export function isContextOverflowErrorText(text) { + if (!text) + return false; + if (NON_CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(text))) + return false; + return CONTEXT_OVERFLOW_PATTERNS.some((pattern) => pattern.test(text)); +} +/** + * Classifies a provider error by DESCENDING evidence strength over the + * normalized evidence (Error, string, or plain stream-error-part object): + * abort wrapper → structured account state → the provider's structured + * overflow code → numeric status fallbacks (402, 429, 401; numeric fields, + * never substrings) → bare 413 + * (HTTP: request entity too large — itself input-side evidence, Cerebras sends it with no body) → + * vetoable free-text overflow relations → generic 5xx → free-text abort → weak word + * heuristics. Specific overflow evidence outranks a generic 5xx because + * proxies (LiteLLM) wrap provider overflows in 503s; the weak heuristics + * rank last so "generate" can never become a rate limit. Numeric status + * outranks free text because the text spans the whole chain, JSON key names + * included. + */ +export function classifyError(error) { + if (RetryError.isInstance(error) && error.reason === 'abort') + return 'Abort'; + const facts = providerFailureDiagnosticFacts(error); + return facts ? classifyProviderFacts(facts) : 'Other'; +} +function classifyProviderFacts(facts) { + const { target: classificationTarget, evidence } = facts; + const { text, statusCode, code, structuredCodes } = evidence; + const normalizedCode = code.toLowerCase(); + if (code === OPENAI_RESPONSES_WEBSOCKET_TRANSPORT_ERROR) + return 'Network'; + // Structured capacity evidence outranks account-state codes: a gateway can + // surface capacity as a quota-shaped error, and the retry guidance differs. + if (PROVIDER_CAPACITY_CODES.has(normalizedCode) || + structuredCodes.some((c) => PROVIDER_CAPACITY_CODES.has(c))) { + return 'ProviderCapacity'; + } + if (structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value))) + return 'Auth'; + if (structuredCodes.some((value) => PROVIDER_BILLING_CODES.has(value))) + return 'ProviderBilling'; + if (structuredCodes.some((value) => PROVIDER_PERMISSION_CODES.has(value))) + return 'ProviderPermission'; + if (structuredCodes.some((value) => PROVIDER_USAGE_LIMIT_CODES.has(value))) + return 'UsageLimit'; + if (structuredCodes.some((value) => PROVIDER_RATE_LIMIT_CODES.has(value))) + return 'RateLimit'; + if (structuredCodes.some((value) => PROVIDER_UNAVAILABLE_CODES.has(value))) + return 'ProviderUnavailable'; + // The provider's structured context code is unconditional input-overflow + // evidence. It outranks outer transport status and message fallbacks. + if (structuredCodes.some((c) => CONTEXT_OVERFLOW_PROVIDER_CODES.has(c))) + return 'ContextLength'; + // Numeric status outranks free text: the text spans the whole cause chain + // including JSON key names, so a 429 whose body reads "request aborted: + // too many requests" must keep its rate-limit class and retry-after + // handling, and a 500 carrying an `aborted` key stays ProviderUnavailable + // (#2521). + if (statusCode === '402' || code === '402') + return 'ProviderBilling'; + if (statusCode === '429' || code === '429') + return 'RateLimit'; + if (statusCode === '401' || code === '401') + return 'Auth'; + // A bare 403 is intentionally unknown: providers use it for valid-key + // permission failures, guardrails, subscription limits, and occasionally + // authentication. The provider's bounded diagnostic remains available. + if (statusCode === '413' || code === '413') + return 'ContextLength'; + // Free-text overflow relations on the composite text, veto-first inside. + if (isContextOverflowErrorText(text)) + return 'ContextLength'; + if (structuredCodes.some((c) => PROVIDER_UNAVAILABLE_PROVIDER_CODES.has(c))) + return 'ProviderUnavailable'; + if (/^5\d\d$/.test(statusCode) || /^5\d\d$/.test(code)) + return 'ProviderUnavailable'; + if (text.includes('abort')) + return 'Abort'; + // Weak word heuristics remain as compatibility fallbacks after all stronger + // provider facts. They must not override a structured account state. + if (/\brate\b|rate[_-]?limit/.test(text)) + return 'RateLimit'; + if (isAuthenticationErrorText(text)) + return 'Auth'; + if (text.includes('timeout')) + return 'Timeout'; + if (text.includes('network') || + text.includes('fetch') || + /\btypeerror\b.*\bterminated\b/.test(text)) + return 'Network'; + return classificationTarget instanceof Error ? classificationTarget.name || 'Other' : 'Other'; +} +export function errorPresentationFromClass(errorClass) { + switch (errorClass) { + case 'ContextLength': + return { reason: 'context_overflow', message: 'Context window exceeded' }; + case 'Timeout': + return { reason: 'timeout', message: 'Request timed out' }; + case 'Auth': + return { reason: 'auth', message: 'Authentication failed' }; + case 'ProviderBilling': + return { reason: 'provider_billing', message: 'Provider billing required' }; + case 'ProviderPermission': + return { reason: 'provider_permission', message: 'Provider access denied' }; + case 'ProviderUnavailable': + return { reason: 'provider_unavailable', message: 'Provider returned an error' }; + case 'RateLimit': + return { reason: 'rate_limit', message: 'Rate limit exceeded' }; + case 'UsageLimit': + return { reason: 'usage_limit', message: 'Usage limit reached' }; + case 'Network': + return { reason: 'network', message: 'Network error' }; + default: + return {}; + } +} diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 04cb5969a4..7dccf6be08 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -204,13 +204,45 @@ export function providerRetryMetadata(error: unknown): ProviderRetryMetadata { return providerRetryMetadataFromFacts(facts); } +/** + * The classification that retry and failure projections must agree on: a + * weak text-derived `Auth` class is demoted when the envelope's own numeric + * status contradicts it (an "authentication" wording behind a 500 is a + * provider-unavailable event, not an account state), so the Runtime retry + * owner never skips a transient 5xx because of the original wording. + */ +function normalizedClassifiedClass(facts: ProviderErrorFacts): string { + const classified = classifyProviderFacts(facts); + if ( + classified === 'Auth' && + !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value)) + ) { + const rawStatus = + facts.evidence.statusCode || + firstProviderField(facts.summarySources, ['statusCode', 'status']); + const numericStatus = Number(rawStatus); + const httpStatus = + Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 + ? numericStatus + : undefined; + if (httpStatus !== undefined && httpStatus !== 401) return 'Other'; + } + return classified; +} + function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetryMetadata { const { evidence } = facts; if (RUNTIME_RETRYABLE_ERROR_CODES.has(evidence.code)) return { retryable: true }; - const status = Number(evidence.statusCode || evidence.code); - const errorClass = classifyProviderFacts(facts); + // Same numeric-status resolution as the failure projection, so a nested + // envelope status participates in the retry decision identically. + const rawStatus = + evidence.statusCode || + evidence.code || + firstProviderField(facts.summarySources, ['statusCode', 'status']); + const status = Number(rawStatus); + const errorClass = normalizedClassifiedClass(facts); // Account state cannot be repaired by immediately repeating the same // physical request, even when a provider reports it through HTTP 429. An // Abort is by definition not repairable by repeating the request either — @@ -355,15 +387,25 @@ function normalizeProviderError(error: unknown): ProviderErrorFacts | undefined } catch { text = String(target).toLowerCase(); } + // Classification evidence must see the same nested envelope the failure + // projection reads: a JSON-shaped error carries its code/status inside + // `data.error`, not on the target itself. Without this merge, structured + // account-state and capacity codes hide behind a transport status and the + // weak word heuristics misread them (#2521). + const sources = providerFailureSources(target); + const nestedStatusCode = firstProviderField(sources, ['statusCode', 'status']); + const nestedCode = firstProviderField(sources, ['code']); + const nestedStructuredCodes: string[] = []; + for (const record of sources.records) collectStructuredCodes(record, nestedStructuredCodes); return { target, evidence: { text, - statusCode: field('statusCode') || field('status'), - code: field('code'), - structuredCodes, + statusCode: field('statusCode') || field('status') || nestedStatusCode, + code: field('code') || nestedCode, + structuredCodes: [...new Set([...structuredCodes, ...nestedStructuredCodes])], }, - summarySources: providerFailureSources(target), + summarySources: sources, ...(responseHeaders ? { responseHeaders } : {}), }; } @@ -453,16 +495,8 @@ export function providerFailureResult(error: unknown): ProviderFailureResult { Number.isInteger(numericStatus) && numericStatus >= 100 && numericStatus <= 599 ? numericStatus : undefined; - const classified = classifyProviderFacts(facts); - const errorClass = normalizedProviderFailureClass( - classified === 'Auth' && - httpStatus !== undefined && - httpStatus !== 401 && - !facts.evidence.structuredCodes.some((value) => PROVIDER_AUTH_CODES.has(value)) - ? 'Other' - : classified, - httpStatus, - ); + const classified = normalizedClassifiedClass(facts); + const errorClass = normalizedProviderFailureClass(classified, httpStatus); // When a provider message is projected, its code must come from the same // chain link — never an outer link's code paired with an inner link's // message (#2521).