From be8136878ca7259916181cb1d2bd6f42cf9b729e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 18 Feb 2026 21:38:52 -0500 Subject: [PATCH] fix: propagate active billing model across lifecycle errors --- ...pi-agent.auth-profile-rotation.e2e.test.ts | 53 +++++++++++++++++++ ...i-embedded-subscribe.handlers.lifecycle.ts | 2 + ...scribe.lifecycle-billing-error.e2e.test.ts | 35 ++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/agents/pi-embedded-subscribe.lifecycle-billing-error.e2e.test.ts diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index 49327be8ac0..a45fe4e1284 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -518,6 +518,59 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { } }); + it("uses the active erroring model in billing failover errors", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-")); + try { + await writeAuthStore(agentDir); + runEmbeddedAttemptMock.mockResolvedValueOnce( + makeAttempt({ + assistantTexts: [], + lastAssistant: buildAssistant({ + stopReason: "error", + errorMessage: "insufficient credits", + provider: "openai", + model: "mock-rotated", + }), + }), + ); + + let thrown: unknown; + try { + await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:test:billing-failover-active-model", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig({ fallbacks: ["openai/mock-2"] }), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: "openai:p1", + authProfileIdSource: "user", + timeoutMs: 5_000, + runId: "run:billing-failover-active-model", + }); + } catch (err) { + thrown = err; + } + + expect(thrown).toMatchObject({ + name: "FailoverError", + reason: "billing", + provider: "openai", + model: "mock-rotated", + }); + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toContain("openai (mock-rotated) returned a billing error"); + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("skips profiles in cooldown when rotating after failure", async () => { vi.useFakeTimers(); try { diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 3bb9cc3318f..7158bfa246d 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -35,6 +35,8 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) { const friendlyError = formatAssistantErrorText(lastAssistant, { cfg: ctx.params.config, sessionKey: ctx.params.sessionKey, + provider: lastAssistant.provider, + model: lastAssistant.model, }); emitAgentEvent({ runId: ctx.params.runId, diff --git a/src/agents/pi-embedded-subscribe.lifecycle-billing-error.e2e.test.ts b/src/agents/pi-embedded-subscribe.lifecycle-billing-error.e2e.test.ts new file mode 100644 index 00000000000..669bb50c3ec --- /dev/null +++ b/src/agents/pi-embedded-subscribe.lifecycle-billing-error.e2e.test.ts @@ -0,0 +1,35 @@ +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { describe, expect, it, vi } from "vitest"; +import { createStubSessionHarness } from "./pi-embedded-subscribe.e2e-harness.js"; +import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; + +describe("subscribeEmbeddedPiSession lifecycle billing errors", () => { + it("includes provider and model context in lifecycle billing errors", () => { + const { session, emit } = createStubSessionHarness(); + const onAgentEvent = vi.fn(); + + subscribeEmbeddedPiSession({ + session, + runId: "run-billing-error", + onAgentEvent, + sessionKey: "test-session", + }); + + const assistantMessage = { + role: "assistant", + stopReason: "error", + errorMessage: "insufficient credits", + provider: "Anthropic", + model: "claude-3-5-sonnet", + } as AssistantMessage; + + emit({ type: "message_update", message: assistantMessage }); + emit({ type: "agent_end" }); + + const lifecycleError = onAgentEvent.mock.calls.find( + (call) => call[0]?.stream === "lifecycle" && call[0]?.data?.phase === "error", + ); + expect(lifecycleError).toBeDefined(); + expect(lifecycleError?.[0]?.data?.error).toContain("Anthropic (claude-3-5-sonnet)"); + }); +});