diff --git a/packages/pi-agent-core/src/agent-loop.ts b/packages/pi-agent-core/src/agent-loop.ts index 413c20b36..43eb3693f 100644 --- a/packages/pi-agent-core/src/agent-loop.ts +++ b/packages/pi-agent-core/src/agent-loop.ts @@ -138,7 +138,36 @@ async function runLoop( } // Stream assistant response - const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); + let message: AssistantMessage; + try { + message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); + } catch (error) { + // Critical failure before stream started (e.g. getApiKey threw, credentials in + // backoff, network unavailable). Convert to a graceful error message so the + // agent loop can end cleanly instead of crashing with an unhandled rejection. + const errorText = error instanceof Error ? error.message : String(error); + message = { + role: "assistant", + content: [], + api: config.model.api, + provider: config.model.provider, + model: config.model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: signal?.aborted ? "aborted" : "error", + errorMessage: errorText, + timestamp: Date.now(), + }; + stream.push({ type: "message_start", message: { ...message } }); + stream.push({ type: "message_end", message }); + currentContext.messages.push(message); + } newMessages.push(message); if (message.stopReason === "error" || message.stopReason === "aborted") { diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 13e030e49..deb18023a 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -2348,6 +2348,22 @@ export class AgentSession { return true; } + + // All credentials are backed off. For quota-exhausted errors the backoff is very + // long (30+ min), so retrying immediately is futile and will only produce + // confusing secondary errors (e.g. "Authentication failed"). Give up now and + // surface the original quota error to the user. + if (errorType === "quota_exhausted") { + this._emit({ + type: "auto_retry_end", + success: false, + attempt: this._retryAttempt, + finalError: message.errorMessage, + }); + this._retryAttempt = 0; + this._resolveRetry(); + return false; + } } this._retryAttempt++; diff --git a/packages/pi-coding-agent/src/core/auth-storage.test.ts b/packages/pi-coding-agent/src/core/auth-storage.test.ts index 50b7ffedc..6d4445c28 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.test.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.test.ts @@ -178,6 +178,44 @@ describe("AuthStorage — rate-limit backoff", () => { }); }); +// ─── areAllCredentialsBackedOff ─────────────────────────────────────────────── + +describe("AuthStorage — areAllCredentialsBackedOff", () => { + it("returns false when no credentials are configured", () => { + const storage = inMemory({}); + assert.equal(storage.areAllCredentialsBackedOff("anthropic"), false); + }); + + it("returns false when credentials exist and none are backed off", async () => { + const storage = inMemory({ anthropic: makeKey("sk-abc") }); + assert.equal(storage.areAllCredentialsBackedOff("anthropic"), false); + }); + + it("returns true when the single credential is backed off", async () => { + const storage = inMemory({ anthropic: makeKey("sk-only") }); + await storage.getApiKey("anthropic"); + storage.markUsageLimitReached("anthropic"); + assert.equal(storage.areAllCredentialsBackedOff("anthropic"), true); + }); + + it("returns false when at least one credential is still available", async () => { + const storage = inMemory({ anthropic: [makeKey("sk-1"), makeKey("sk-2")] }); + await storage.getApiKey("anthropic"); // uses index 0 + storage.markUsageLimitReached("anthropic"); // backs off index 0 + // index 1 is still available + assert.equal(storage.areAllCredentialsBackedOff("anthropic"), false); + }); + + it("returns true when all credentials are backed off", async () => { + const storage = inMemory({ anthropic: [makeKey("sk-1"), makeKey("sk-2")] }); + await storage.getApiKey("anthropic"); // uses index 0 + storage.markUsageLimitReached("anthropic"); // backs off index 0 + await storage.getApiKey("anthropic"); // uses index 1 + storage.markUsageLimitReached("anthropic"); // backs off index 1 + assert.equal(storage.areAllCredentialsBackedOff("anthropic"), true); + }); +}); + // ─── getAll truncation ──────────────────────────────────────────────────────── describe("AuthStorage — getAll()", () => { diff --git a/packages/pi-coding-agent/src/core/auth-storage.ts b/packages/pi-coding-agent/src/core/auth-storage.ts index 30beef551..bce7d910e 100644 --- a/packages/pi-coding-agent/src/core/auth-storage.ts +++ b/packages/pi-coding-agent/src/core/auth-storage.ts @@ -470,6 +470,20 @@ export class AuthStorage { this.remove(provider); } + /** + * Returns true when the provider has credentials configured but all of them + * are currently in a backoff window (e.g. rate-limited or quota exhausted). + * Returns false when there are no credentials or at least one is available. + */ + areAllCredentialsBackedOff(provider: string): boolean { + const credentials = this.getCredentialsForProvider(provider); + if (credentials.length === 0) return false; + for (let i = 0; i < credentials.length; i++) { + if (!this.isCredentialBackedOff(provider, i)) return false; + } + return true; + } + /** * Check if a credential index is currently backed off. */ diff --git a/packages/pi-coding-agent/src/core/sdk.ts b/packages/pi-coding-agent/src/core/sdk.ts index 5832c1c71..98dc78f7a 100644 --- a/packages/pi-coding-agent/src/core/sdk.ts +++ b/packages/pi-coding-agent/src/core/sdk.ts @@ -322,6 +322,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const model = agent.state.model; const isOAuth = model && modelRegistry.isUsingOAuth(model); if (isOAuth) { + // If credentials exist but are all in a backoff window (quota / rate-limit), + // surface a specific message instead of the misleading "Authentication failed". + if (modelRegistry.authStorage.areAllCredentialsBackedOff(resolvedProvider)) { + throw new Error( + `Rate limit in effect for "${resolvedProvider}". ` + + `Please wait before retrying or switch to a different model.`, + ); + } throw new Error( `Authentication failed for "${resolvedProvider}". ` + `Credentials may have expired or network is unavailable. ` +