diff --git a/.changeset/loud-ravens-cheer.md b/.changeset/loud-ravens-cheer.md new file mode 100644 index 00000000..c412ddd7 --- /dev/null +++ b/.changeset/loud-ravens-cheer.md @@ -0,0 +1,5 @@ +--- +"@martian-engineering/lossless-claw": patch +--- + +Fix summarizer auth-error detection so real provider auth envelopes nested under `data` or `body` still trigger handling, while successful summary payloads in `message` or `response` no longer cause false-positive auth failures. diff --git a/src/summarize.ts b/src/summarize.ts index 92362192..165c4e56 100644 --- a/src/summarize.ts +++ b/src/summarize.ts @@ -59,6 +59,18 @@ const AUTH_ERROR_TEXT_PATTERN = /\b401\b|unauthorized|unauthorised|invalid[_ -]?token|invalid[_ -]?api[_ -]?key|authentication failed|authorization failed|missing scope|insufficient scope|model\.request\b/i; const AUTH_ERROR_STATUS_KEYS = ["status", "statusCode", "status_code"] as const; const AUTH_ERROR_NESTED_KEYS = ["error", "response", "cause", "details", "data", "body"] as const; +const AUTH_ERROR_TOP_LEVEL_KEYS = [ + "error", + "errorMessage", + "status", + "statusCode", + "status_code", + "code", + "details", + "cause", + "data", + "body", +] as const; type ProviderAuthFailure = { statusCode?: number; @@ -421,6 +433,21 @@ function extractAuthFailureStatusCode(value: unknown, depth = 0): number | undef return undefined; } +function hasTopLevelAuthInspectionKeys(value: Record): boolean { + return AUTH_ERROR_TOP_LEVEL_KEYS.some((key) => key in value); +} + +function looksLikeThrownError(value: Record): boolean { + return ( + (typeof value.name === "string" && /\berror\b/i.test(value.name)) || + "stack" in value || + (typeof value.message === "string" && + !("content" in value) && + !("response" in value) && + !("output" in value)) + ); +} + function pickAuthInspectionValue(value: unknown): unknown { if (!isRecord(value)) { return value; @@ -430,23 +457,36 @@ function pickAuthInspectionValue(value: unknown): unknown { } const subset: Record = {}; - for (const key of [ - "error", - "errorMessage", - "message", - "status", - "statusCode", - "status_code", - "code", - "details", - "response", - "cause", - ]) { + const hasTopLevelAuthKeys = hasTopLevelAuthInspectionKeys(value); + const errorLike = value instanceof Error || looksLikeThrownError(value); + + for (const key of AUTH_ERROR_TOP_LEVEL_KEYS) { if (key in value) { subset[key] = value[key]; } } - return Object.keys(subset).length > 0 ? subset : value; + + // Only inspect top-level message payloads when the envelope already looks + // error-shaped. Successful summary responses also use `message`. + if ((hasTopLevelAuthKeys || errorLike) && "message" in value) { + subset.message = value.message; + } + + // `response` can carry either an error payload or successful summary text. + // Include it only when the surrounding or nested shape already looks like an + // error envelope. + if ("response" in value) { + const response = value.response; + if ( + hasTopLevelAuthKeys || + (isRecord(response) && hasTopLevelAuthInspectionKeys(response)) || + (isRecord(response) && looksLikeThrownError(response)) + ) { + subset.response = response; + } + } + + return Object.keys(subset).length > 0 ? subset : {}; } /** @internal Exported for testing only. */ diff --git a/test/summarize.test.ts b/test/summarize.test.ts index 8cb2ad81..d98188f2 100644 --- a/test/summarize.test.ts +++ b/test/summarize.test.ts @@ -821,6 +821,96 @@ describe("createLcmSummarizeFromLegacyParams", () => { } }); + it("still detects auth failures nested under a top-level data envelope", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const deps = makeDeps({ + resolveModel: vi.fn(() => ({ + provider: "openai-codex", + model: "gpt-5.4", + })), + complete: vi.fn(async () => ({ + content: [], + data: { + statusCode: 401, + message: "Missing required scope: model.request", + }, + })), + }); + + const result = await createLcmSummarizeFromLegacyParams({ + deps, + legacyParams: { provider: "openai-codex", model: "gpt-5.4" }, + }); + + await expect(result!.fn("C".repeat(8_000), false)).rejects.toBeInstanceOf( + LcmProviderAuthError, + ); + expect(vi.mocked(deps.complete)).toHaveBeenCalledTimes(2); + + const warningText = consoleWarn.mock.calls.flatMap((call) => call.map(String)).join(" "); + expect(warningText).toContain("provider auth error (401 / missing model.request scope)"); + } finally { + consoleWarn.mockRestore(); + } + }); + + it("does not misclassify response-envelope summary text as an auth error", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const deps = makeDeps({ + complete: vi.fn(async () => ({ + content: [], + response: { + text: "Summary of a debugging session about 401 invalid api key failures.", + }, + })), + }); + + const summarize = await createSummarizeFn({ + deps, + legacyParams: { provider: "anthropic", model: "claude-opus-4-5" }, + }); + + const summary = await summarize!("D".repeat(8_000), false); + + expect(summary).toBe("Summary of a debugging session about 401 invalid api key failures."); + expect(vi.mocked(deps.complete)).toHaveBeenCalledTimes(1); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleWarn.mockRestore(); + } + }); + + it("does not misclassify message-envelope summary text as an auth error", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const deps = makeDeps({ + complete: vi.fn(async () => ({ + content: [], + message: { + text: "Conversation summary: the team fixed an unauthorized error caused by a stale token.", + }, + })), + }); + + const summarize = await createSummarizeFn({ + deps, + legacyParams: { provider: "anthropic", model: "claude-opus-4-5" }, + }); + + const summary = await summarize!("E".repeat(8_000), false); + + expect(summary).toBe( + "Conversation summary: the team fixed an unauthorized error caused by a stale token.", + ); + expect(vi.mocked(deps.complete)).toHaveBeenCalledTimes(1); + expect(consoleWarn).not.toHaveBeenCalled(); + } finally { + consoleWarn.mockRestore(); + } + }); + it("falls back to the next resolved model when the preferred model fails auth", async () => { const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); try {