diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index 840d36f97c7..9afbe5c5a51 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -3084,6 +3084,9 @@ describe("OpenCodeServerDriver", () => { const observed = await events; const failure = observed.find(event => event.eventType === "turn.failed"); expect(failure?.payload.error).toMatchObject({ providerFailure: { provider: "openrouter", category: "overloaded", message: "Provider is busy." } }); + // Provider text stays in display fields: the notice summary, never the failure message. + expect((failure?.payload.error as Record).message).toBeUndefined(); + expect(observed.find(event => event.eventType === "provider.notice.recorded")?.payload.summary).toBe("Provider is busy."); expect(JSON.stringify(failure)).not.toContain("private-body"); } finally { await session.close({ reason: "test complete" }); } }); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 9010a7a9646..4b2f24b79e5 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1972,6 +1972,11 @@ class OpenCodeHarnessSession implements HarnessSession { this.#activeTurnId = null; return; } + // OpenCode's provider errors (`APIError`) carry the message under `data`. + const providerMessage = redact( + text(providerError.message, text(providerErrorData.message, "OpenCode session failed.")), + this.#runtime.sensitiveValues, + ).slice(0, 4000); this.#emit( "provider.notice.recorded", { @@ -1982,10 +1987,7 @@ class OpenCodeHarnessSession implements HarnessSession { scope: "turn", recoverable: false, userActionable: true, - summary: redact( - text(record(properties.error).message, "OpenCode session failed."), - this.#runtime.sensitiveValues, - ).slice(0, 4000), + summary: providerMessage, }, { turnId, itemId: `${turnId}:session-error` }, ); diff --git a/packages/paperclip-runner/src/drivers/opencode/provider-failure.ts b/packages/paperclip-runner/src/drivers/opencode/provider-failure.ts index 03ffecfac84..920c1d60a3e 100644 --- a/packages/paperclip-runner/src/drivers/opencode/provider-failure.ts +++ b/packages/paperclip-runner/src/drivers/opencode/provider-failure.ts @@ -22,6 +22,9 @@ function providerErrorEnvelope(message: string): { message?: string; httpStatus? }; } +// OpenRouter reports an unknown model ID as " is not a valid model ID". +const MODEL_UNAVAILABLE = /not a valid model|model_not_found|model[^\n]*(?:does not exist|not found|not supported)/i; + export function openCodeProviderFailure(error: Record, provider: string, environment: NodeJS.ProcessEnv = {}) { const data = error.data && typeof error.data === "object" ? error.data as Record : {}; @@ -31,5 +34,7 @@ export function openCodeProviderFailure(error: Record, provider return createProviderFailure({ provider, phase: "turn", message: envelope.message ?? message, httpStatus: data.statusCode ?? envelope.httpStatus, providerCode: data.code ?? envelope.providerCode ?? error.name, requestId: data.requestID, - category: error.name === "ProviderAuthError" ? "authentication" : undefined, environment }); + category: error.name === "ProviderAuthError" ? "authentication" + : data.isRetryable !== true && MODEL_UNAVAILABLE.test(envelope.message ?? message) ? "model_unavailable" : undefined, + environment }); } diff --git a/packages/paperclip-runner/src/drivers/provider-failure.test.ts b/packages/paperclip-runner/src/drivers/provider-failure.test.ts index fb128eaa21a..f0e48ca19a4 100644 --- a/packages/paperclip-runner/src/drivers/provider-failure.test.ts +++ b/packages/paperclip-runner/src/drivers/provider-failure.test.ts @@ -95,6 +95,14 @@ describe("provider failure boundary", () => { .toMatchObject({ category: "rate_limit", retryability: "unknown" }); }); + it("classifies OpenRouter's invalid model ID as an unavailable model unless OpenCode allows a retry", () => { + const data = { message: "openrouter/unavailable-model is not a valid model ID", statusCode: 400 }; + expect(openCodeProviderFailure({ name: "APIError", data: { ...data, isRetryable: false } }, "openrouter")) + .toMatchObject({ category: "model_unavailable", retryability: "requires_action", httpStatus: 400 }); + expect(openCodeProviderFailure({ name: "APIError", data: { ...data, isRetryable: true } }, "openrouter")?.category) + .not.toBe("model_unavailable"); + }); + it("extracts OpenCode API error fields without copying response bodies or headers", () => { const failure = openCodeProviderFailure({ name: "APIError", data: { message: "Service is busy.", statusCode: 503, responseBody: "private request", responseHeaders: { authorization: "secret" } } }, "openrouter"); diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts index 4ffdcc26909..e77ab6e17ed 100644 --- a/packages/paperclip-runner/src/native-session-runtime.test.ts +++ b/packages/paperclip-runner/src/native-session-runtime.test.ts @@ -1,4 +1,5 @@ import { validatePrpStructuredRunResult } from "./protocol/replay-contract.js"; +import { createProviderFailure } from "./drivers/provider-failure.js"; import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -1501,9 +1502,22 @@ describe("executeNativeSession recovery", () => { message: "The model service failed while processing output.", modelRejected: false, }, - ])( - "preserves structured provider failure and model retry classification ($recoverable, $modelRejected)", - async ({ recoverable, message, modelRejected }) => { + { + // A driver's closed category marks the rejection; no provider text is needed. + recoverable: false, + message: "Provider turn failed", + modelRejected: true, + category: "model_unavailable" as const, + }, + { + recoverable: true, + message: "Provider turn failed", + modelRejected: false, + category: "model_unavailable" as const, + }, + ] as Array<{ recoverable: boolean; message: string; modelRejected: boolean; category?: "model_unavailable" }>)( + "preserves structured provider failure and model retry classification ($recoverable, $modelRejected, $category)", + async ({ recoverable, message, modelRejected, category }) => { const capabilities = { resume: true, typedEvents: true, @@ -1519,7 +1533,9 @@ describe("executeNativeSession recovery", () => { }, async *events() { yield runnerEvent(1, "turn.failed", { - error: { code: "RUNTIME", recoverable, message }, + error: { code: "RUNTIME", recoverable, message, ...(category ? { providerFailure: createProviderFailure({ + provider: "openrouter", phase: "turn", message: "openrouter/unavailable-model is not a valid model ID", httpStatus: 400, category, + }) } : {}) }, }); }, async startTurn() { diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts index a03ac2c6aee..c2ed976c24c 100644 --- a/packages/paperclip-runner/src/native-session-runtime.ts +++ b/packages/paperclip-runner/src/native-session-runtime.ts @@ -2860,11 +2860,15 @@ export async function executeNativeSession( // Retain the older consumer's permanent-model classification while // preserving structured provider metadata. A provider explicitly // permitting retry must not become permanent merely from its text. + // A driver's closed provider-failure category also marks a model + // rejection, without putting provider text in the failure message. + const providerFailure = readProviderFailure(failure.providerFailure); const modelRejected = !recoverable && - /issue with the selected model|model_not_found|invalid model|model[^\n]*(?:does not exist|not found|not supported)/i.test( - message, - ); + (providerFailure?.category === "model_unavailable" || + /issue with the selected model|model_not_found|invalid model|model[^\n]*(?:does not exist|not found|not supported)/i.test( + message, + )); throw new NativeProviderTerminalFailure( typeof failure.code === "string" ? failure.code @@ -2873,7 +2877,7 @@ export async function executeNativeSession( modelRejected ? `native_provider_model_rejected: ${message}` : message, - readProviderFailure(failure.providerFailure), + providerFailure, ); } if (settledCompletion === null && options.resolveMissingResult) { diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 5ce7ab0c1d3..d140b35a031 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -18791,6 +18791,18 @@ it.each([ ).toBe("native_event_replay_conflict"); }); +it("saves a runner-marked model rejection from a provider terminal as a model choice", () => { + const rejected = new NativeProviderTerminalFailure("provider_turn_failed", false, + "native_provider_model_rejected: openrouter/unavailable-model is not a valid model ID"); + const code = nativeSessionFailureSourceCode(rejected); + expect(code).toBe("native_provider_model_rejected"); + expect(nativeSessionFailureDisposition(1, new Date(), code)).toEqual({ phase: "terminal_failure", failureCode: code, nextAttemptAt: null }); + // A recoverable terminal or an unmarked message stays a generic provider failure. + expect(nativeSessionFailureSourceCode(new NativeProviderTerminalFailure("provider_turn_failed", true, + "native_provider_model_rejected: openrouter/unavailable-model is not a valid model ID"))).toBe("native_provider_terminal_failed"); + expect(nativeSessionFailureSourceCode(new NativeProviderTerminalFailure("provider_turn_failed", false, + "openrouter/unavailable-model is not a valid model ID"))).toBe("native_provider_terminal_failed"); +}); it("keeps an ACPX missing runtime package failure actionable and stops automatic retries", () => { const failure = new Error('PRP command session.open failed: {"result":{"code":"command_execution_failed","message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=runtime_package_missing)"},"status":"failed"}'); const code = nativeSessionFailureSourceCode(failure); diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 5f8201f8ab7..acc9ace7e60 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -7437,6 +7437,10 @@ export function nativeSessionFailureSourceCode( ) ) return "native_event_replay_conflict"; + // The runner marks a non-recoverable rejection of the selected model in the + // terminal message; it needs a model choice, not a generic provider failure. + if (!error.recoverable && /^native_provider_model_rejected:/.test(error.message)) + return "native_provider_model_rejected"; return "native_provider_terminal_failed"; } if (error instanceof NativeSessionProtocolIntegrityError)