Merge pull request #5030 from usr-bin-roygbiv/fix/provider-native-compaction
fix(compaction): retry after AWS credential failures
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user