From 3ec92a199c8eec1e14e5d1339d9cbbf6be8aef18 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 11 Aug 2026 18:15:34 +0800 Subject: [PATCH 01/15] 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 | 8 +- .../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 | 98 ++++++++++++++--- 10 files changed, 305 insertions(+), 24 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 7f62272903..f081c54fab 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -288,7 +288,9 @@ export interface DesktopConversationCopy { timeout: string; auth: string; providerBilling: string; + providerPermission: string; rateLimit: string; + usageLimit: string; network: string; provider: string; stepCap: string; @@ -296,7 +298,7 @@ export interface DesktopConversationCopy { permission: string; restarted: string; sandboxBoundaryClosed: string; - recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'partial' | 'toolRecord' | 'retry' | 'sandboxBoundaryClosed', string>; + recovery: Record<'safeResume' | 'stepCap' | 'toolError' | 'connection' | 'account' | 'partial' | 'toolRecord' | 'retry' | 'sandboxBoundaryClosed', string>; }; } @@ -566,7 +568,7 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { unknown: '未知错误', contextOverflow: '上下文窗口已超出限制', timeout: '请求超时', auth: '鉴权失败', providerBilling: '模型服务计费受限', rateLimit: '触发模型速率限制', network: '网络错误', provider: '模型服务返回错误', stepCap: '达到工具步骤上限', tool: '工具调用失败', permission: '等待权限确认', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭', recovery: { safeResume: '检查当前状态后,可尝试安全恢复', stepCap: '任务可能尚未完成,可以继续', toolError: '先检查工具结果,再决定是否重试', connection: '先检查模型连接或登录状态', partial: '已保留部分输出,可从这里继续', toolRecord: '工具记录已保留,重试前先看结果', retry: '没有执行工具,可直接重试', 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: '没有执行工具,可直接重试', 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' }, @@ -768,7 +770,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', 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', 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', 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 184e54a686..0c136cc9a1 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -17,10 +17,14 @@ export function describeSessionErrorReason(reason: string | undefined, locale: U return copy.auth; case 'provider_billing': return copy.providerBilling; + 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 721eb22881..1fa680c6f8 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -119,7 +119,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; @@ -132,7 +134,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; @@ -171,9 +178,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 f7537e0b70..3a514b4c4e 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -118,6 +118,31 @@ describe('Maka Pi TUI transcript', () => { assert.doesNotMatch(stripAnsi(renderMakaPiStatusLine({ ...meta(), goal: null }, 120)), /goal/); }); + 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 67aa5efe8c..60663c76a5 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -626,6 +626,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 49ef12bbbf..390dae0c22 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -7,6 +7,7 @@ import { z } from 'zod/v4'; import { classifyError, providerFailureDiagnostic, + errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, } from '../provider-error-classification.js'; @@ -385,6 +386,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 c3a24291db..bfc1a941d6 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -980,12 +980,16 @@ function modelFailureKind(errorClass: string): ModelFailureKind { return 'network'; case 'ProviderBilling': return 'provider_billing'; + 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'; } @@ -1003,12 +1007,16 @@ function errorClassFromFailureKind(kind: ModelFailureKind): string { return 'Network'; case 'provider_billing': return 'ProviderBilling'; + 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 b18f954123..01bf64131a 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -319,9 +319,11 @@ export type ModelFailureKind = | 'context_overflow' | 'network' | '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 a0709b2e93..0eb97497ff 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -13,6 +13,29 @@ 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_CODES: ReadonlySet = new Set([ + 'overloaded_error', + 'provider_overloaded', + 'provider_unavailable', +]); + /** * A provider failure normalized into classification evidence. classifyError's * real input domain is NOT just Error instances: a request-level failure is @@ -31,7 +54,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[]; } @@ -118,16 +141,33 @@ 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 === 'RateLimit' || status === 429) { if (retryAfterMs === undefined || retryAfterMs === null) return { retryable: false }; return { retryable: true, retryAfterMs }; } + const sdkRetryable = + typeof facts.target === 'object' && + facts.target !== null && + typeof (facts.target as { isRetryable?: unknown }).isRetryable === 'boolean' + ? (facts.target as { isRetryable: boolean }).isRetryable + : undefined; const retryable = - errorClass === 'Network' || - status === 408 || - status === 409 || - (status >= 500 && status <= 599); + sdkRetryable ?? + (errorClass === 'Network' || + status === 408 || + status === 409 || + (status >= 500 && status <= 599)); if (!retryable) return { retryable: false }; if (retryAfterMs === null) return { retryable: false }; return { @@ -136,18 +176,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 { @@ -164,8 +213,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 { @@ -564,9 +614,9 @@ 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 → 402 → 429 → 401/403 (numeric fields, never substrings) → the - * provider's structured overflow code → bare 413 (HTTP: request entity too - * large — itself input-side evidence, Cerebras sends it with no body) → + * abort → structured account state → 402 → 429 → 401 (numeric fields, + * never substrings) → the provider's structured overflow code → bare 413 + * (HTTP: request entity too large — itself input-side evidence, Cerebras sends it with no body) → * vetoable free-text overflow relations → generic 5xx → weak word * heuristics. Specific overflow evidence outranks a generic 5xx because * proxies (LiteLLM) wrap provider overflows in 503s; the weak heuristics @@ -583,10 +633,20 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { const { text, statusCode, code, structuredCodes } = evidence; if (text.includes('abort')) return 'Abort'; 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'; 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 (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'; @@ -594,10 +654,8 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { // Free-text overflow relations on the composite text, veto-first inside. if (isContextOverflowErrorText(text)) return 'ContextLength'; 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'; @@ -623,10 +681,14 @@ export function errorPresentationFromClass(errorClass: string): { 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: From 9f9cb70fa1ec212f56b23dbe56e00170fbfaa7b0 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 13 Aug 2026 12:36:02 +0800 Subject: [PATCH 02/15] 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 7f03c8c9e6..0cfeeaefe7 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -3,7 +3,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'; @@ -43,8 +42,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 1fa680c6f8..71c8790fcb 100644 --- a/apps/desktop/src/renderer/session-status-presentation.ts +++ b/apps/desktop/src/renderer/session-status-presentation.ts @@ -129,7 +129,7 @@ export function describeTurnErrorClass(errorClass: string | undefined, locale: U if (lower === 'provider_unavailable' || /\b5\d\d\b/.test(lower)) 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 df1ce7529af6e70af7041410789e51ff396e8fb9 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 03/15] 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 e82202dfc9..3bbabdf826 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1066,6 +1066,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 ec866a1a37..fe1d19a53c 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -177,6 +177,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; } @@ -478,7 +485,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 0be760549c..7ce4434984 100644 --- a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -419,6 +419,7 @@ describe('AiSdkFlow seam', () => { code: 'AUTH', reason: 'auth_failed', message: 'no token', + boundedProviderMessage: true, }), ev({ type: 'complete', stopReason: 'error' }), ], @@ -428,10 +429,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 60663c76a5..0eb39ae115 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -646,11 +646,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 09526e4e29..23271b5503 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -544,6 +544,59 @@ describe('models.dev provider conformance', () => { assert.deepEqual(requestedModels, ['kimi-k2.6']); }); + 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 390dae0c22..6701d7cc51 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -422,9 +422,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 f781fb1cf8..d6e9448386 100644 --- a/packages/runtime/src/ai-sdk-flow.ts +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -560,6 +560,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 5bbe8678c2..d561dfd299 100644 --- a/packages/runtime/src/connection-effect-outcome.ts +++ b/packages/runtime/src/connection-effect-outcome.ts @@ -1,4 +1,5 @@ import type { ModelDiscoverySource, ModelInfo, ProviderType } from '@maka/core/llm-connections'; +import { classifyError } from './provider-error-classification.js'; export interface ConnectionEffectConnection { readonly providerType: ProviderType; @@ -54,10 +55,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 bfc1a941d6..0f45c67c24 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -492,6 +492,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, }; @@ -951,10 +952,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 01bf64131a..26dc0cb9eb 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -335,6 +335,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 0eb97497ff..c7f1be76bb 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -389,22 +389,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 07277741c1..4b8683feb8 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -26,6 +26,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; @@ -450,7 +451,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( @@ -495,6 +519,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 31d1b648c1179b0b1edae74139d9449bbe5674a8 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 04/15] 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 8e71bc8d0d..6948ba984c 100644 --- a/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts +++ b/packages/runtime-host/src/__tests__/canonical-session-projection.test.ts @@ -359,6 +359,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, @@ -399,6 +400,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 55a82c196c..a02106b37e 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -631,6 +631,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 a1a7b3810a..32f0c30a35 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -1321,6 +1321,8 @@ describe('Runtime Host bootstrap protocol', () => { terminalEventId: 'event-1', failureClass: 'unknown', failureMessage: 'Provider request failed', + failureCode: 'permission_error', + boundedProviderMessage: true, }, }; @@ -1336,6 +1338,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 a3b0e462e6..0f129f8350 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -81,6 +81,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 6a37dc2457..78d8a4c712 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -401,6 +401,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 c5f2ee055f..9fa65c4122 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -169,6 +169,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'; @@ -309,6 +313,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, @@ -630,13 +639,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( @@ -647,6 +659,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 b585e2d587..b0ddf1a125 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -46,14 +46,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, @@ -61,7 +67,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'); @@ -93,7 +101,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 66ef30aee63bb52f99ae8b966222c3f2aa2a4248 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 17 Aug 2026 21:34:07 +0800 Subject: [PATCH 05/15] 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 0cfeeaefe7..6b0890017f 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -46,8 +46,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 22a91cec59ffb6737c7e6bee5648f85f16e290c9 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 19:02:47 +0800 Subject: [PATCH 06/15] 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 | 82 +++++++++++++------ packages/runtime/src/test-connection.ts | 36 +++----- 19 files changed, 444 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 84ff9c332e..d95c7a11f7 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 @@ -332,6 +332,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 97f57bd631..bae1e54197 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -303,6 +303,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 7039c18242..f01b19c67d 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -41,21 +41,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 e791fdf99b..2d1e03f7c7 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -58,7 +58,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; @@ -73,14 +73,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; } @@ -91,10 +100,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 9906819742..20028bf2a0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -87,6 +87,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 821e46b0d9..514bebad2e 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -351,6 +351,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 a02106b37e..ef1d4ea90c 100644 --- a/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effect-coordinator.test.ts @@ -645,7 +645,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, }; @@ -661,6 +672,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 0a54ba8986..a6cfde5fca 100644 --- a/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/connection-effects-protocol.test.ts @@ -137,6 +137,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); @@ -172,6 +180,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 a588d71be8..1cdcebac40 100644 --- a/packages/runtime-host/src/protocol/connection-effects.ts +++ b/packages/runtime-host/src/protocol/connection-effects.ts @@ -10,6 +10,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, @@ -138,6 +139,7 @@ export type ConnectionTestProjection = readonly latencyMs: number | null; readonly statusCode: number | null; readonly errorClass: ConnectionEffectFailureClass; + readonly providerFailure?: ProviderFailureResult; }; export type ConnectionTestRunResult = @@ -393,6 +395,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 { @@ -407,6 +410,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 732b3c9223..7ca2eaae6f 100644 --- a/packages/runtime-host/src/server/connection-effect-coordinator.ts +++ b/packages/runtime-host/src/server/connection-effect-coordinator.ts @@ -469,6 +469,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 23271b5503..da1988bc54 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -572,6 +572,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 6701d7cc51..09d106f100 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -7,6 +7,7 @@ import { z } from 'zod/v4'; import { classifyError, providerFailureDiagnostic, + providerFailureResult, errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, @@ -71,6 +72,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'], [ @@ -474,6 +491,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 2936f8cd03..d302dd9e9c 100644 --- a/packages/runtime/src/__tests__/provider-request-telemetry.test.ts +++ b/packages/runtime/src/__tests__/provider-request-telemetry.test.ts @@ -991,7 +991,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' }, @@ -1011,15 +1011,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 d561dfd299..36b59175dd 100644 --- a/packages/runtime/src/connection-effect-outcome.ts +++ b/packages/runtime/src/connection-effect-outcome.ts @@ -1,5 +1,6 @@ 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; @@ -21,6 +22,7 @@ export type ConnectionEffectErrorKind = export interface ConnectionEffectError { readonly kind: ConnectionEffectErrorKind; readonly statusCode?: number; + readonly providerFailure?: ProviderFailureResult; } export type ConnectionModelDiscoveryEffectOutcome = @@ -58,18 +60,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 0f45c67c24..9a134447f7 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -40,7 +40,7 @@ import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime. import { classifyError, errorPresentationFromClass, - providerFailureSummary, + providerFailureResult, providerRetryMetadata, } from './provider-error-classification.js'; import { @@ -950,16 +950,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 c7f1be76bb..47956f7155 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -1,5 +1,6 @@ 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'; /** @@ -71,14 +72,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; @@ -135,6 +133,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 }; @@ -265,6 +267,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 @@ -296,6 +303,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']); @@ -325,23 +338,29 @@ export function providerFailureSummary(error: unknown): ProviderFailureSummary | }; } -const DURABLE_PROVIDER_ERROR_CLASSES: ReadonlySet = new Set([ - 'Abort', - 'Auth', - 'ContextLength', - 'Network', - '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; @@ -353,26 +372,35 @@ 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 evidence can legitimately arrive behind a - // generic 4xx/5xx proxy response and remains stronger than the wrapper code. - if (classified === 'ContextLength') return classified; - if (httpStatus === 401 || httpStatus === 403) return 'Auth'; +function normalizedProviderFailureClass( + classified: string, + httpStatus: number | undefined, +): ProviderFailureResult['errorClass'] { + // 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'; @@ -383,7 +411,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 4b8683feb8..8cf5c6de68 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -26,7 +26,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; @@ -435,23 +435,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, }; } @@ -460,16 +451,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': @@ -522,6 +503,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 f004e06e436feff8a5a0fc688aedfd00b584e438 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 20:03:44 +0800 Subject: [PATCH 07/15] 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 3a514b4c4e..f0e8a9076e 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -130,6 +130,7 @@ describe('Maka Pi TUI transcript', () => { recoverable: false, code: 'permission_error', message, + boundedProviderMessage: true, }), ); @@ -143,6 +144,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 10be3973c5..a75142f51c 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -783,7 +783,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 67e158159a..a52298ba16 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -154,6 +154,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 fe1d19a53c..1d69d49b22 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -691,6 +691,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 0eb39ae115..1c6c9b9271 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -597,6 +597,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', () => { @@ -653,6 +654,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 da1988bc54..36b8b8bdb0 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -634,6 +634,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 09d106f100..d6c7099058 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -507,6 +507,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 9a134447f7..47e71aebd8 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -484,7 +484,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(), @@ -951,11 +951,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 47956f7155..344a86f9a1 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -83,6 +83,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; @@ -303,12 +307,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']); @@ -335,6 +344,7 @@ function providerFailureSummaryFromFacts( return { message: truncateUtf8(summary, PROVIDER_FAILURE_SUMMARY_MAX_BYTES, '…'), ...(code || statusCode ? { code: code ?? statusCode } : {}), + boundedProviderMessage: message !== undefined && hasProviderMessageSource(facts), }; } @@ -372,7 +382,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 = @@ -388,7 +406,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 } + : {}), + } : {}), }; } @@ -481,6 +504,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 8cf5c6de68..ad574b5ee0 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -439,7 +439,9 @@ async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise Date: Tue, 18 Aug 2026 20:20:26 +0800 Subject: [PATCH 08/15] 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 51d0b31ac4..8726f5912b 100644 --- a/packages/runtime/src/__tests__/model-adapter-onerror.test.ts +++ b/packages/runtime/src/__tests__/model-adapter-onerror.test.ts @@ -80,7 +80,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 ff1ee0c0e6..c5838ce3b7 100644 --- a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts +++ b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts @@ -360,7 +360,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']); @@ -369,7 +378,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 b8c74e15bd80cf84e12b4788302982cc1b469c74 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 21:31:21 +0800 Subject: [PATCH 09/15] 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 | 35 +++++++- .../provider-error-classification.test.ts | 23 +++++ .../src/provider-error-classification.ts | 84 ++++++++++++++----- 6 files changed, 174 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 6b0890017f..f56fc0acbc 100644 --- a/apps/desktop/src/renderer/model-connection-errors.ts +++ b/apps/desktop/src/renderer/model-connection-errors.ts @@ -49,11 +49,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 f0e8a9076e..6cddcbd45f 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -145,6 +145,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 a75142f51c..6fde7fbfa0 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -783,10 +783,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; @@ -817,6 +814,36 @@ export function applyMakaSessionEventToTranscript( } } +function transcriptErrorMessage(event: Extract): string { + const stableReason = (() => { + switch (event.reason?.toLowerCase()) { + case 'context_overflow': + return 'Context window exceeded'; + case 'timeout': + return 'Request timed out'; + case 'auth': + return 'Authentication failed'; + case 'provider_billing': + return 'Provider billing required'; + case 'provider_permission': + return 'Provider access denied'; + case 'provider_unavailable': + return 'Provider returned an error'; + case 'rate_limit': + return 'Rate limit exceeded'; + case 'usage_limit': + return 'Usage limit reached'; + case 'network': + return 'Network error'; + default: + return undefined; + } + })(); + if (stableReason) return stableReason; + if (event.boundedProviderMessage === true && event.message.length > 0) return event.message; + return 'The task run failed. Try again later.'; +} + function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { switch (item.kind) { case 'user': diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index d6c7099058..38d22f27f9 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -67,6 +67,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 344a86f9a1..99b57ce82e 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -63,6 +63,8 @@ interface ProviderErrorFacts { target: unknown; evidence: ProviderErrorEvidence; summarySources: ProviderFailureSources; + messageSources?: ProviderFailureSources; + boundedProviderMessageSource?: boolean; bareMessage?: string; responseHeaders?: Record; } @@ -135,7 +137,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); } @@ -305,7 +307,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; @@ -320,7 +322,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']) ?? @@ -391,8 +393,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']); @@ -416,6 +417,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, @@ -439,28 +458,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 { @@ -494,7 +535,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, @@ -505,6 +546,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 ( @@ -695,7 +739,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 7c1b509eef7fc96e8c81aee0540f58c0a5b2c072 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 18 Aug 2026 23:53:11 +0800 Subject: [PATCH 10/15] fix(cli): localize runtime error notices Generated-by: Maka --- .../cli/src/__tests__/pi-transcript.test.ts | 18 ++- packages/cli/src/pi-transcript.ts | 113 +++++++++++------- 2 files changed, 88 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 6cddcbd45f..13e74fe569 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -138,6 +138,7 @@ describe('Maka Pi TUI transcript', () => { kind: 'notice', level: 'error', text: message, + runtimeError: {}, }); assert.match( renderMakaPiTranscript(state, meta(), 100).map(stripAnsi).join('\n'), @@ -161,8 +162,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', () => { @@ -182,6 +189,7 @@ describe('Maka Pi TUI transcript', () => { kind: 'notice', level: 'error', text: 'Provider request failed safely.', + runtimeError: {}, }); }); @@ -201,8 +209,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 6fde7fbfa0..4871458896 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -177,7 +177,14 @@ export type MakaPiTranscriptEntry = */ hidden?: 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; @@ -783,7 +790,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; @@ -814,36 +823,6 @@ export function applyMakaSessionEventToTranscript( } } -function transcriptErrorMessage(event: Extract): string { - const stableReason = (() => { - switch (event.reason?.toLowerCase()) { - case 'context_overflow': - return 'Context window exceeded'; - case 'timeout': - return 'Request timed out'; - case 'auth': - return 'Authentication failed'; - case 'provider_billing': - return 'Provider billing required'; - case 'provider_permission': - return 'Provider access denied'; - case 'provider_unavailable': - return 'Provider returned an error'; - case 'rate_limit': - return 'Rate limit exceeded'; - case 'usage_limit': - return 'Usage limit reached'; - case 'network': - return 'Network error'; - default: - return undefined; - } - })(); - if (stableReason) return stableReason; - if (event.boundedProviderMessage === true && event.message.length > 0) return event.message; - return 'The task run failed. Try again later.'; -} - function chatItemToTranscriptEntries(item: ChatItem): MakaPiTranscriptEntry[] { switch (item.kind) { case 'user': @@ -1173,7 +1152,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'), + ); } state.renderGeometry.entryFirstLine = entryFirstLine; @@ -1262,6 +1243,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 @@ -1274,15 +1256,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[] { switch (entry.kind) { case 'user': return renderUserBlock(entry.text, width); @@ -1297,11 +1283,19 @@ function renderTranscriptEntryBlock(entry: MakaPiTranscriptEntry, width: number) case 'tool': return renderToolBlock(entry, width, entry.expanded); case 'notice': - return renderNotice(entry, width); + return renderNotice( + entry, + width, + entry.runtimeError ? transcriptErrorMessage(entry, locale) : entry.text, + ); } } -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': @@ -1320,7 +1314,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: status/duration flip, // progress/output deltas append, and resultVersion advances whenever a @@ -1752,9 +1748,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 bd0c100876585290ba5aeb2fa4b0ddaa2a424704 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 08:46:21 +0800 Subject: [PATCH 11/15] 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 ++-- .../src/__tests__/host-kernel.test.ts | 2 +- .../__tests__/provider-conformance.test.ts | 1 + .../provider-error-classification.test.ts | 6 +++++- .../__tests__/scoped-fetch-transport.test.ts | 1 - .../src/provider-error-classification.ts | 8 +++----- 10 files changed, 55 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 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 f01b19c67d..f78158c888 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -3,6 +3,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:'; @@ -48,6 +49,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 0c136cc9a1..8b46b97d02 100644 --- a/apps/desktop/src/renderer/session-error-presentation.ts +++ b/apps/desktop/src/renderer/session-error-presentation.ts @@ -31,3 +31,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 2d1e03f7c7..dfe16a5000 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -13,6 +13,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 { @@ -72,6 +73,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 fd1ac64a4b..19705b279d 100644 --- a/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts +++ b/packages/runtime-host/src/__tests__/handshake-compatibility.test.ts @@ -56,7 +56,7 @@ test('emits the legacy desktop surface shim in the raw Client hello', async () = ); }); -test('rejects an epoch-23 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) => { @@ -68,7 +68,7 @@ test('rejects an epoch-23 Host before any domain command', async () => { hostEpoch, connectionId: 'forged-epoch-connection', selectedProtocol: RUNTIME_HOST_PROTOCOL_VERSION, - compatibilityEpoch: 23, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, compositionId: 'maka.interactive', compositionRevision: '1', state: 'ready', diff --git a/packages/runtime-host/src/__tests__/host-kernel.test.ts b/packages/runtime-host/src/__tests__/host-kernel.test.ts index 94b284bbe4..56361a85af 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -1735,7 +1735,7 @@ describe('non-serving Runtime Host kernel', () => { clientInstanceId: 'epoch-mismatch-client', protocolMin: CURRENT_PROTOCOL.min, protocolMax: CURRENT_PROTOCOL.max, - compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH + 1, + compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, compositionId: 'maka.interactive', }); const response = decodeHostFrame(await transport.read(2_000)); diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 36b8b8bdb0..a96f104437 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -635,6 +635,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 38d22f27f9..0b77a11da3 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -452,6 +452,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', }); @@ -535,7 +540,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 c5838ce3b7..409ce6f914 100644 --- a/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts +++ b/packages/runtime/src/network/__tests__/scoped-fetch-transport.test.ts @@ -367,7 +367,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 99b57ce82e..a90fc5d446 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -406,12 +406,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, } : {}), }; @@ -746,7 +744,6 @@ export function classifyError(error: unknown): string { function classifyProviderFacts(facts: ProviderErrorFacts): string { const { target: classificationTarget, evidence } = facts; const { text, statusCode, code, structuredCodes } = evidence; - if (text.includes('abort')) return 'Abort'; 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'; @@ -756,6 +753,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 66dd41c41f2036b9f9857c6dc3d55930f1486cdf Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 18:31:22 +0800 Subject: [PATCH 12/15] fix(runtime): prioritize structured context overflow Generated-by: Maka --- .../provider-error-classification.test.ts | 17 +++++++++++++++++ .../src/provider-error-classification.ts | 11 ++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index 0b77a11da3..d654b13b4f 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -90,6 +90,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 a90fc5d446..847d1f8503 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -727,8 +727,9 @@ 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 overflow code → bare 413 + * abort wrapper → structured account state → the provider's structured + * overflow code → text/status fallbacks (abort, 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 → weak word * heuristics. Specific overflow evidence outranks a generic 5xx because @@ -753,6 +754,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'; @@ -760,9 +764,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 1607c6522102d4f13afb85f6eb9f0236f64b4318 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 20:07:16 +0800 Subject: [PATCH 13/15] 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 d654b13b4f..6106037229 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -99,6 +99,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 847d1f8503..f976fa3741 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -155,7 +155,8 @@ function providerRetryMetadataFromFacts(facts: ProviderErrorFacts): ProviderRetr errorClass === 'Auth' || errorClass === 'ProviderBilling' || errorClass === 'ProviderPermission' || - errorClass === 'UsageLimit' + errorClass === 'UsageLimit' || + errorClass === 'ContextLength' ) { return { retryable: false }; } From 57ab21c595d5d238afd59eec03cefae644ba62e0 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Wed, 19 Aug 2026 21:21:42 +0800 Subject: [PATCH 14/15] fix(runtime-host): advance provider failure epoch Generated-by: Codex --- packages/runtime-host/src/__tests__/protocol.test.ts | 7 +++++++ packages/runtime-host/src/protocol/index.ts | 8 +++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 32f0c30a35..53cdff1ac3 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -98,6 +98,13 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 25); }); + test('publishes a new compatibility epoch for structured provider failures', () => { + // Current main already uses epoch 26. Failed-Turn and connection-test + // responses now carry required structured failure fields, so epoch-26 peers + // must be rejected before either strict decoder sees the incompatible shape. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 26); + }); + test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b18b69ab14..2e8b495bbc 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -72,11 +72,13 @@ 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. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 28 as const; -// 28: Relay model profiles carry the Fast service-tier declaration. Older -// peers cannot safely preserve that Runtime Policy field. +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 29 as const; // 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 e1e8ab4260f35d221ddd27b76257b662fba83cbd Mon Sep 17 00:00:00 2001 From: me2seeks Date: Thu, 20 Aug 2026 20:56:38 +0800 Subject: [PATCH 15/15] 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 --- packages/cli/src/pi-transcript.ts | 4 +- .../src/__tests__/host-kernel.test.ts | 79 ++++++++++--------- .../src/__tests__/protocol.test.ts | 7 -- .../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 | 77 +++++++++++++++--- 7 files changed, 211 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/pi-transcript.ts b/packages/cli/src/pi-transcript.ts index 4871458896..66a6d475f7 100644 --- a/packages/cli/src/pi-transcript.ts +++ b/packages/cli/src/pi-transcript.ts @@ -1776,7 +1776,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 56361a85af..1846964580 100644 --- a/packages/runtime-host/src/__tests__/host-kernel.test.ts +++ b/packages/runtime-host/src/__tests__/host-kernel.test.ts @@ -1720,43 +1720,50 @@ describe('non-serving Runtime Host kernel', () => { }); test('rejects a handshake whose compatibility epoch does not match', async () => { - 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: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1, - compositionId: 'maka.interactive', + // 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, }); - 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(); - } - }); + 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 () => { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 53cdff1ac3..32f0c30a35 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -98,13 +98,6 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 25); }); - test('publishes a new compatibility epoch for structured provider failures', () => { - // Current main already uses epoch 26. Failed-Turn and connection-test - // responses now carry required structured failure fields, so epoch-26 peers - // must be rejected before either strict decoder sees the incompatible shape. - assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 26); - }); - test('selects the highest mutually supported protocol and rejects a gap', () => { assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0); assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 1c6c9b9271..8ddb64ba6e 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -204,6 +204,29 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(shaped.message, 'Rate limit exceeded'); }); + 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]; @@ -620,7 +643,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); @@ -645,13 +671,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 6106037229..0496d31962 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -11,6 +11,7 @@ import { errorPresentationFromClass, providerFailureSummary, providerRetryMetadata, + taxonomySafeProviderCode, } from '../provider-error-classification.js'; describe('Provider error classification', () => { @@ -564,6 +565,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 47e71aebd8..f9ede5f9d3 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -42,6 +42,7 @@ import { errorPresentationFromClass, providerFailureResult, providerRetryMetadata, + taxonomySafeProviderCode, } from './provider-error-classification.js'; import { withProviderGenerateTracking, @@ -958,12 +959,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 f976fa3741..76865743d9 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -37,6 +37,35 @@ const PROVIDER_UNAVAILABLE_CODES: ReadonlySet = new Set([ '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). + */ +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' || + 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; +} + /** * A provider failure normalized into classification evidence. classifyError's * real input domain is NOT just Error instances: a request-level failure is @@ -66,6 +95,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; } @@ -150,8 +182,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' || @@ -321,9 +357,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']) ?? @@ -394,7 +431,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']); @@ -477,12 +517,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 ?? '', @@ -548,7 +593,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( @@ -729,13 +779,15 @@ 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 wrapper → structured account state → the provider's structured - * overflow code → text/status fallbacks (abort, 402, 429, 401; numeric fields, + * 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 → weak word + * 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. + * 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: unknown): string { if (RetryError.isInstance(error) && error.reason === 'abort') return 'Abort'; @@ -758,7 +810,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'; @@ -769,6 +825,7 @@ function classifyProviderFacts(facts: ProviderErrorFacts): string { // Free-text overflow relations on the composite text, veto-first inside. if (isContextOverflowErrorText(text)) return 'ContextLength'; 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';