Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { stripVTControlCharacters } from "node:util";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { initTheme } from "../theme/theme.js";
import { AssistantMessageComponent } from "./assistant-message.js";

initTheme("dark");

const quotaMessage = "You have no credits remaining. Please add credits to continue.";

for (const text of ["", "The answer so far."]) {
test(`quota Error stays singular through streaming, redraw, and restore (${text ? "text" : "empty"})`, () => {
const streaming: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text }],
api: "openai-responses",
provider: "mock",
model: "quota-test",
timestamp: 1,
stopReason: "stop",
usage: {
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
};
const failed: AssistantMessage = { ...streaming, stopReason: "error", errorMessage: quotaMessage };
const component = new AssistantMessageComponent();
const visibleLines = () => component.render(160)
.map((line) => stripVTControlCharacters(line).trim()).filter(Boolean);
const body = text ? [text] : [];
const finalLines = [...body, `Error: ${quotaMessage}`];

component.updateContent(streaming, true);
assert.deepEqual(visibleLines(), body);
component.updateContent(failed, false);
assert.deepEqual(visibleLines(), finalLines);
assert.equal(component.getSpacingContent(), "visible");
// Stable signatures reuse child components; invalidation rebuilds them.
component.updateContent(failed, false);
assert.deepEqual(visibleLines(), finalLines);
component.invalidate();
assert.deepEqual(visibleLines(), finalLines);
component.setExpanded(true);
assert.deepEqual(visibleLines(), finalLines);
component.setExpanded(false);
assert.deepEqual(visibleLines(), finalLines);

const restored = new AssistantMessageComponent(failed);
assert.deepEqual(restored.render(160).map((line) => stripVTControlCharacters(line).trim()).filter(Boolean), finalLines);
// Updating the same row to a later successful message must clear stale Error chrome.
component.updateContent(streaming, false);
assert.deepEqual(visibleLines(), body);
});
}
162 changes: 162 additions & 0 deletions apps/tui/src/native-to-pi/quota-exceeded.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Trace: L2-DES-APP-010
* Verifies: quota failures survive Native projection, rendering, and restore.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { stripVTControlCharacters } from "node:util";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { AssistantMessageComponent } from "../../lib/coding-agent/src/modes/interactive/components/assistant-message.js";
import { initTheme } from "../../lib/coding-agent/src/modes/interactive/theme/theme.js";
import { NativeAgentConnection } from "../native-agent-connection.js";
import { createStreamState, nativeNotificationToPiEvents } from "./events.js";
import { applyFailedTurnsToAgentMessages, nativeItemsToAgentMessages } from "./messages.js";

initTheme("dark");

const quotaMessage = "You have no credits remaining. Please add credits to continue.";
const partialText = "The answer so far.";
const completedAt = "2026-10-24T00:00:00.000Z";
const failedTurn = {
id: "turn-quota",
status: "failed",
error: { errorCode: "QUOTA_EXCEEDED_ERROR", message: quotaMessage },
completedAt,
};
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };

function renderedLines(messages: Array<Record<string, unknown>>): string[] {
return messages.flatMap((message) =>
new AssistantMessageComponent(message as unknown as AssistantMessage)
.render(160)
.map((line) => stripVTControlCharacters(line).trim())
.filter(Boolean),
);
}

for (const textState of ["no tokens", "streaming text", "completed text"] as const) {
for (const statusNotification of ["absent", "before completion", "after completion"] as const) {
test(`quota failure: ${textState}, failed status ${statusNotification}`, () => {
const state = createStreamState();
assert.deepEqual(nativeNotificationToPiEvents("turn/started", { turn: { id: failedTurn.id } }, state), [
{ type: "agent_start" },
]);
const endedMessages: Array<Record<string, unknown>> = [];
if (textState !== "no tokens") {
nativeNotificationToPiEvents("item/assistantMessage/delta", { itemId: "a1", delta: partialText }, state);
if (textState === "completed text") {
const itemEvents = nativeNotificationToPiEvents("item/completed", {
id: "a1", item: { type: "assistantMessage", text: partialText },
}, state);
endedMessages.push(...itemEvents.filter((event) => event.type === "message_end")
.map((event) => event.message as Record<string, unknown>));
}
}
if (statusNotification === "before completion") {
assert.deepEqual(nativeNotificationToPiEvents("turn/statusChanged", {
turnId: failedTurn.id, status: "failed",
}, state), [{ type: "agent_end" }]);
}

const events = nativeNotificationToPiEvents("turn/completed", { turn: failedTurn }, state);
const message = events[0]?.message as Record<string, unknown>;
assert.equal(typeof message?.timestamp, "number");
assert.deepEqual(events, [
{
type: "message_end",
message: {
role: "assistant",
content: [{ type: "text", text: textState === "streaming text" ? partialText : "" }],
timestamp: message.timestamp,
stopReason: "error",
errorMessage: quotaMessage,
usage,
},
},
{ type: "agent_end" },
]);
endedMessages.push(message);
assert.deepEqual(renderedLines(endedMessages), [
...(textState === "no tokens" ? [] : [partialText]),
`Error: ${quotaMessage}`,
]);
assert.deepEqual(state, {
...createStreamState(),
assistantTextByItem: new Map(textState === "streaming text" ? [["a1", partialText]] : []),
});
if (statusNotification === "after completion") {
assert.deepEqual(nativeNotificationToPiEvents("turn/statusChanged", {
turnId: failedTurn.id, status: "failed",
}, state), [{ type: "agent_end" }]);
}
// The next turn must get a new stream, not reuse the failed one.
assert.deepEqual(nativeNotificationToPiEvents("turn/started", { turn: { id: "next-turn" } }, state), [
{ type: "agent_start" },
]);
const nextEvents = nativeNotificationToPiEvents("item/assistantMessage/delta", {
itemId: "a2", delta: "Next answer.",
}, state);
assert.deepEqual(nextEvents.map((event) => event.type), ["message_start", "message_update"]);
assert.deepEqual(renderedLines([nextEvents[1].message as Record<string, unknown>]), ["Next answer."]);
});
}
}

for (const text of ["", partialText]) {
test(`quota failure ends connection activity with ${text ? "existing text" : "zero text"}`, async () => {
const connection = NativeAgentConnection.create({ writeLine: () => assert.fail("unexpected RPC") });
const events: Array<Record<string, unknown>> = [];
const unsubscribe = connection.subscribe((event) => {
if (event.type === "session_event") events.push(event.event as unknown as Record<string, unknown>);
});
try {
const initialState = await connection.getState();
connection.pushChunk(`${JSON.stringify({ jsonrpc: "2.0", method: "turn/started", params: {
turn: { id: failedTurn.id },
} })}\n`);
assert.deepEqual(await connection.getState(), { ...initialState, isStreaming: true });
if (text) {
connection.pushChunk(`${JSON.stringify({ jsonrpc: "2.0", method: "item/assistantMessage/delta",
params: { itemId: "a1", delta: text },
})}\n`);
}
connection.pushChunk(`${JSON.stringify({ jsonrpc: "2.0", method: "turn/completed",
params: { turn: failedTurn },
})}\n`);
assert.deepEqual(await connection.getState(), initialState);
const terminalEvents = events.filter((event) => event.type === "message_end" || event.type === "agent_end");
assert.deepEqual(terminalEvents.map((event) => event.type), ["message_end", "agent_end"]);
assert.deepEqual(renderedLines([terminalEvents[0].message as Record<string, unknown>]), [
...(text ? [text] : []), `Error: ${quotaMessage}`,
]);
} finally {
unsubscribe();
connection.dispose();
}
});

for (const durableWarning of ["present", "absent"] as const) {
test(`quota failure restore: ${text ? "existing text" : "zero text"}, warning ${durableWarning}`, () => {
const timestamp = Date.parse(completedAt);
const items = [
...(text ? [{ id: "a1", createdAt: completedAt, item: { type: "assistantMessage", text } }] : []),
...(durableWarning === "present" ? [{
id: "quota-warning", createdAt: completedAt,
item: { type: "warning", code: failedTurn.error.errorCode, message: quotaMessage },
}] : []),
];
const restored = applyFailedTurnsToAgentMessages(nativeItemsToAgentMessages(items), [failedTurn]);
const expected: Array<Record<string, unknown>> = durableWarning === "present" && text ? [{
role: "assistant", content: [{ type: "text", text }], timestamp,
stopReason: null, errorMessage: null, usage,
}] : [];
expected.push({
role: "assistant", content: [{ type: "text", text: durableWarning === "present" ? "" : text }],
timestamp, stopReason: "error", errorMessage: quotaMessage, usage,
});
assert.deepEqual(restored, expected);
assert.deepEqual(renderedLines(restored), [...(text ? [text] : []), `Error: ${quotaMessage}`]);
assert.deepEqual(applyFailedTurnsToAgentMessages(restored, [failedTurn]), restored);
});
}
}
52 changes: 52 additions & 0 deletions crates/provider/src/openai/error_payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ pub(super) fn provider_error_from_payload(
return Some(error);
}

// A depleted balance is not a transient rate limit, even when the
// payload also carries numeric code 429. Keep this distinct from authentication.
if [
payload.kind.as_deref(),
payload.code.as_ref().and_then(Value::as_str),
]
.into_iter()
.flatten()
.any(|value| {
value.eq_ignore_ascii_case("insufficient_quota")
|| value.eq_ignore_ascii_case("credit_balance_exhausted")
}) {
return Some(ProviderError::QuotaExceededError {
message,
provider_name,
});
}

Some(match status_code {
Some(401 | 403) => ProviderError::AuthenticationError {
message,
Expand Down Expand Up @@ -223,4 +241,38 @@ mod tests {
})
);
}

#[test]
fn quota_metadata_takes_precedence_over_retryable_status_codes() {
let request = ModelRequest {
model_slug: ModelProfileKey::Generic,
model: "provider-model".to_string(),
system: None,
messages: Vec::new(),
max_tokens: 16,
tools: None,
hosted_tools: Vec::new(),
sampling: Default::default(),
request_thinking: None,
reasoning_effort: None,
extra_body: None,
};
let expected = json!({
"error_kind": "quota_exceeded_error",
"message": "No credits remain",
"provider_name": "openai",
});
for payload in [
json!({"message": "No credits remain", "type": "insufficient_quota"}),
json!({"message": "No credits remain", "code": "credit_balance_exhausted"}),
json!({"message": "No credits remain", "type": "insufficient_quota", "code": 429}),
] {
let error = provider_error_from_payload(&json!({"error": payload}), &request)
.expect("quota provider error");
assert_eq!(
serde_json::to_value(error).expect("serialize quota error"),
expected
);
}
}
}
Loading
Loading