Merge pull request #5030 from usr-bin-roygbiv/fix/provider-native-compaction

fix(compaction): retry after AWS credential failures
This commit is contained in:
Can Bölük
2026-07-10 12:15:55 +02:00
committed by GitHub
6 changed files with 84 additions and 15 deletions
+1
View File
@@ -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
+4 -1
View File
@@ -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;
+5
View File
@@ -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" }),
+1
View File
@@ -4,6 +4,7 @@
### 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))
- Fixed full-context forks cold-missing OpenAI prompt caches by persisting an inherited provider prompt-cache key separately from the new OMP session id, adding `--prompt-cache-key` for explicit cache affinity, and dropping automatic inheritance when startup changes the model, thinking level, system prompt, or tool schema. ([#5035](https://github.com/can1357/oh-my-pi/issues/5035))
- Fixed Codex advisor requests using local `-advisor` session labels as provider session IDs; advisors now use stable UUIDv7 provider identities while keeping labeled transcript names. ([#5040](https://github.com/can1357/oh-my-pi/issues/5040))
@@ -12024,18 +12024,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;
@@ -12101,7 +12089,7 @@ export class AgentSession {
},
);
} catch (error) {
if (!this.#isCompactionAuthFailure(error)) {
if (!AIError.is(AIError.classify(error, candidate.api), AIError.Flag.AuthFailed)) {
throw error;
}
}
@@ -12804,7 +12792,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;
}
@@ -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");