From cdecf65f8b8d8ce027bfbef639a359ac209afb1d Mon Sep 17 00:00:00 2001 From: usr_bin_roygbiv Date: Fri, 10 Jul 2026 00:40:52 -0500 Subject: [PATCH 1/2] fix(compaction): retry after AWS credential failures --- packages/ai/src/error/flags.ts | 5 +- packages/ai/test/error-aierr.test.ts | 5 ++ packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/session/agent-session.ts | 16 +---- .../compaction-prefer-current-model.test.ts | 71 +++++++++++++++++++ 5 files changed, 86 insertions(+), 15 deletions(-) diff --git a/packages/ai/src/error/flags.ts b/packages/ai/src/error/flags.ts index a61638ea1..f6570b285 100644 --- a/packages/ai/src/error/flags.ts +++ b/packages/ai/src/error/flags.ts @@ -1,5 +1,6 @@ import { isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils"; import type { Api, AssistantMessage } from "../types"; +import { AwsCredentialsError } from "./aws"; import { AnthropicConnectionError, AnthropicConnectionTimeoutError, @@ -346,7 +347,9 @@ export function classify(error: unknown, api?: Api): number { } } - if (link instanceof AnthropicConnectionTimeoutError) { + if (link instanceof AwsCredentialsError) { + kinds |= Flag.AuthFailed; + } else if (link instanceof AnthropicConnectionTimeoutError) { kinds |= Flag.Timeout | Flag.Transient; } else if (link instanceof AnthropicConnectionError) { kinds |= Flag.Transient; diff --git a/packages/ai/test/error-aierr.test.ts b/packages/ai/test/error-aierr.test.ts index 810a76f87..57617b60e 100644 --- a/packages/ai/test/error-aierr.test.ts +++ b/packages/ai/test/error-aierr.test.ts @@ -32,6 +32,11 @@ describe("AIError.classify — structural provider errors", () => { ).toBe(true); }); + it("classifies a typed AWS credential-resolution failure as authFailed", () => { + const id = AIError.classify(new AIError.AwsCredentialsError("opaque provider setup failure", "resolution")); + expect(AIError.is(id, AIError.Flag.AuthFailed)).toBe(true); + }); + it("maps the usage_limit_reached code to usageLimit on a 429", () => { const id = AIError.classify( new AIError.ProviderHttpError("Payment Required", 429, { code: "usage_limit_reached" }), diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6c5d526e2..30578c75c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed compaction aborting instead of trying an authenticated fallback model when Amazon Bedrock credential resolution fails before a request is sent. ([#5030](https://github.com/can1357/oh-my-pi/pull/5030) by [@usr-bin-roygbiv](https://github.com/usr-bin-roygbiv)) + ## [16.3.15] - 2026-07-09 ### Changed diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 3b4274e5f..2654751e3 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -11921,18 +11921,6 @@ export class AgentSession { return candidates; } - #isCompactionAuthFailure(error: unknown): boolean { - if (!(error instanceof Error)) return false; - // Real provider 401/403 — surfaced as `.status` by the compaction layer - // (see `createSummarizationError` in packages/agent/src/compaction/compaction.ts). - // Without this branch, an expired/revoked Anthropic key would bypass the - // authenticated-fallback path and dump the raw HTTP body into the UI. - const status = (error as Error & { status?: number }).status; - if (status === 401 || status === 403) return true; - // pi-native gateway synthetic for "no credential configured" (issue #986). - // Carries no HTTP status, so the legacy message regex stays. - return /auth_unavailable|no auth available/i.test(error.message); - } #buildCompactionAuthError(): Error { const currentModel = this.model; @@ -11997,7 +11985,7 @@ export class AgentSession { }, ); } catch (error) { - if (!this.#isCompactionAuthFailure(error)) { + if (!AIError.is(AIError.classify(error, candidate.api), AIError.Flag.AuthFailed)) { throw error; } } @@ -12689,7 +12677,7 @@ export class AgentSession { const message = error instanceof Error ? error.message : String(error); const id = AIError.classify(error, candidate.api); - if (this.#isCompactionAuthFailure(error)) { + if (AIError.is(id, AIError.Flag.AuthFailed)) { lastError = this.#buildCompactionAuthError(); break; } diff --git a/packages/coding-agent/test/compaction-prefer-current-model.test.ts b/packages/coding-agent/test/compaction-prefer-current-model.test.ts index 0bceded5f..9b6b5adc9 100644 --- a/packages/coding-agent/test/compaction-prefer-current-model.test.ts +++ b/packages/coding-agent/test/compaction-prefer-current-model.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; +import * as AIError from "@oh-my-pi/pi-ai/error"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -99,6 +100,76 @@ describe("compaction prefers the current session model over modelRoles.default", expect(`${firstCandidate.provider}/${firstCandidate.id}`).toBe(`${currentModel.provider}/${currentModel.id}`); }); + it("falls back when the authenticated Bedrock candidate cannot resolve AWS credentials", async () => { + const currentModel = getBundledModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); + const fallbackModel = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!currentModel || !fallbackModel) { + throw new Error("Expected bundled test models to exist"); + } + + const settings = Settings.isolated({ "compaction.keepRecentTokens": 1, "compaction.strategy": "context-full" }); + settings.setModelRole("smol", `${fallbackModel.provider}/${fallbackModel.id}`); + + const agent = new Agent({ + initialState: { + model: currentModel, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + }); + + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey(currentModel.provider, "bedrock-credentials"); + authStorage.setRuntimeApiKey(fallbackModel.provider, "anthropic-token"); + modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings, + modelRegistry, + }); + session.subscribe(() => {}); + + for (const [userText, assistantText] of [ + ["first question", "first answer"], + ["second question", "second answer"], + ] as const) { + const user = userMsg(userText); + const assistant = assistantMsg(assistantText); + session.agent.appendMessage(user); + session.sessionManager.appendMessage(user); + session.agent.appendMessage(assistant); + session.sessionManager.appendMessage(assistant); + } + + const compactSpy = vi.spyOn(compactionModule, "compact").mockImplementation(async (preparation, model) => { + if (model.provider === currentModel.provider && model.id === currentModel.id) { + throw new AIError.AwsCredentialsError("opaque provider setup failure", "resolution"); + } + if (model.provider !== fallbackModel.provider || model.id !== fallbackModel.id) { + throw new Error(`Unexpected compaction model ${model.provider}/${model.id}`); + } + return { + summary: "fallback summary", + shortSummary: "fallback short summary", + firstKeptEntryId: preparation.firstKeptEntryId, + tokensBefore: 42, + details: { provider: model.provider }, + }; + }); + + const result = await session.compact(); + + expect(result.summary).toBe("fallback summary"); + expect(compactSpy).toHaveBeenCalledTimes(2); + expect(compactSpy.mock.calls.map(([, model]) => `${model.provider}/${model.id}`)).toEqual([ + `${currentModel.provider}/${currentModel.id}`, + `${fallbackModel.provider}/${fallbackModel.id}`, + ]); + }); + it("uses compactionModel only for the summary call and leaves the active model unchanged", async () => { const baseCurrentModel = getBundledModel("anthropic", "claude-sonnet-4-5"); const compactionModel = getBundledModel("openai", "gpt-5"); From 3c325cdb6b50d1b962075207736dc56de0d4a3c4 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 10 Jul 2026 12:15:24 +0200 Subject: [PATCH 2/2] docs(ai): added unreleased changelog entry for bedrock error classification --- packages/ai/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6c7954447..237599771 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -22,6 +22,7 @@ - Fixed concurrent reasoning summaries to ignore legacy streaming events under cutoff contract - Fixed sequential-cutoff Codex reasoning summaries repeating earlier content when atomic summary snapshots are replayed or extended. +- Fixed error classification for typed AWS credential-resolution failures (`AwsCredentialsError`) to map them to authentication failures. ([#5030](https://github.com/can1357/oh-my-pi/pull/5030) by [@usr-bin-roygbiv](https://github.com/usr-bin-roygbiv)) ## [16.3.15] - 2026-07-09