From e9f17054866fa693f350c34d96f897bcaa78cb2d Mon Sep 17 00:00:00 2001 From: Isaac the Kaylon Date: Wed, 11 Mar 2026 12:36:13 +0000 Subject: [PATCH 1/5] fix(embedded): surface oauth refresh-token reuse as re-auth required --- ...d-helpers.formatassistanterrortext.test.ts | 12 +++++++ ...dded-helpers.isbillingerrormessage.test.ts | 5 +++ src/agents/pi-embedded-helpers/errors.ts | 33 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 397445067c1..a4d2c06eebb 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -113,6 +113,18 @@ describe("formatAssistantErrorText", () => { expect(formatAssistantErrorText(msg)).toContain("rate limit reached"); }); + it("surfaces OAuth refresh-token reuse as re-auth required", () => { + const msg = makeAssistantError( + 'OAuth token refresh failed for openai-codex: 401 {"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","code":"refresh_token_reused"}}', + ); + expect(formatAssistantErrorText(msg, { provider: "openai-codex" })).toContain( + "Please re-authenticate", + ); + expect(formatAssistantErrorText(msg, { provider: "openai-codex" })).not.toContain( + "rate limit", + ); + }); + it("returns a friendly message for empty stream chunk errors", () => { const msg = makeAssistantError("request ended without sending any chunks"); expect(formatAssistantErrorText(msg)).toBe("LLM request timed out."); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 86fd90e7161..3d6aaee99a6 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -619,6 +619,11 @@ describe("classifyFailoverReason", () => { "auth", ); expect(classifyFailoverReason("Missing scopes: model.request")).toBe("auth"); + expect( + classifyFailoverReason( + 'OAuth token refresh failed for openai-codex: 401 {"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","code":"refresh_token_reused"}}', + ), + ).toBe("auth"); expect( classifyFailoverReason("model_cooldown: All credentials for model gpt-5 are cooling down"), ).toBe("rate_limit"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 4cf347150bf..68a6b6c7759 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -43,6 +43,28 @@ const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try const OVERLOADED_ERROR_USER_MESSAGE = "The AI service is temporarily overloaded. Please try again in a moment."; +function isOauthRefreshReauthRequiredMessage(raw: string): boolean { + if (!raw) { + return false; + } + const lower = raw.toLowerCase(); + return ( + lower.includes("oauth token refresh failed") || + lower.includes("refresh_token_reused") || + lower.includes("refresh token has already been used") || + lower.includes("please try signing in again") || + lower.includes("please try again or re-authenticate") + ); +} + +function formatOauthRefreshReauthCopy(provider?: string): string { + const providerLabel = provider?.trim(); + if (providerLabel) { + return `🔐 ${providerLabel} authentication expired. Please re-authenticate and try again.`; + } + return "🔐 Provider authentication expired. Please re-authenticate and try again."; +} + function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined { if (isRateLimitErrorMessage(raw)) { return RATE_LIMIT_ERROR_USER_MESSAGE; @@ -687,6 +709,10 @@ export function formatAssistantErrorText( return `LLM request rejected: ${invalidRequest[1]}`; } + if (isOauthRefreshReauthRequiredMessage(raw)) { + return formatOauthRefreshReauthCopy(opts?.provider); + } + const transientCopy = formatRateLimitOrOverloadedErrorCopy(raw); if (transientCopy) { return transientCopy; @@ -743,6 +769,10 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo return BILLING_ERROR_USER_MESSAGE; } + if (isOauthRefreshReauthRequiredMessage(trimmed)) { + return formatOauthRefreshReauthCopy(); + } + if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) { return formatRawAssistantErrorForUi(trimmed); } @@ -938,6 +968,9 @@ export function classifyFailoverReason(raw: string): FailoverReason | null { if (reasonFrom402Text) { return reasonFrom402Text; } + if (isOauthRefreshReauthRequiredMessage(raw)) { + return "auth"; + } if (isPeriodicUsageLimitErrorMessage(raw)) { return isBillingErrorMessage(raw) ? "billing" : "rate_limit"; } From 3e138c693c1a5dcf5f5f88dd35540ff13b657c39 Mon Sep 17 00:00:00 2001 From: Isaac the Kaylon Date: Wed, 11 Mar 2026 12:43:00 +0000 Subject: [PATCH 2/5] fix(embedded): prioritize oauth reauth over invalid_request fallback --- ...embedded-helpers.formatassistanterrortext.test.ts | 12 +++++++++++- src/agents/pi-embedded-helpers/errors.ts | 12 +++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index a4d2c06eebb..28d3441bbb8 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -120,8 +120,18 @@ describe("formatAssistantErrorText", () => { expect(formatAssistantErrorText(msg, { provider: "openai-codex" })).toContain( "Please re-authenticate", ); + expect(formatAssistantErrorText(msg, { provider: "openai-codex" })).not.toContain("rate limit"); + }); + + it("surfaces OAuth refresh-token reuse when invalid_request_error fields are type-first", () => { + const msg = makeAssistantError( + 'OAuth token refresh failed for openai-codex: 401 {"error":{"type":"invalid_request_error","message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","code":"refresh_token_reused"}}', + ); + expect(formatAssistantErrorText(msg, { provider: "openai-codex" })).toContain( + "Please re-authenticate", + ); expect(formatAssistantErrorText(msg, { provider: "openai-codex" })).not.toContain( - "rate limit", + "LLM request rejected", ); }); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 68a6b6c7759..a564fec6a67 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -51,9 +51,7 @@ function isOauthRefreshReauthRequiredMessage(raw: string): boolean { return ( lower.includes("oauth token refresh failed") || lower.includes("refresh_token_reused") || - lower.includes("refresh token has already been used") || - lower.includes("please try signing in again") || - lower.includes("please try again or re-authenticate") + lower.includes("refresh token has already been used") ); } @@ -704,15 +702,15 @@ export function formatAssistantErrorText( ); } + if (isOauthRefreshReauthRequiredMessage(raw)) { + return formatOauthRefreshReauthCopy(opts?.provider); + } + const invalidRequest = raw.match(/"type":"invalid_request_error".*?"message":"([^"]+)"/); if (invalidRequest?.[1]) { return `LLM request rejected: ${invalidRequest[1]}`; } - if (isOauthRefreshReauthRequiredMessage(raw)) { - return formatOauthRefreshReauthCopy(opts?.provider); - } - const transientCopy = formatRateLimitOrOverloadedErrorCopy(raw); if (transientCopy) { return transientCopy; From 76c9f69cb7371ff0e38a4e7092b0a9c7888c3ece Mon Sep 17 00:00:00 2001 From: Isaac the Kaylon Date: Wed, 11 Mar 2026 12:50:59 +0000 Subject: [PATCH 3/5] fix(embedded): avoid misclassifying transient oauth refresh failures --- ...-embedded-helpers.formatassistanterrortext.test.ts | 9 +++++++++ .../pi-embedded-helpers.isbillingerrormessage.test.ts | 5 +++++ src/agents/pi-embedded-helpers/errors.ts | 11 +++++++---- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts index 28d3441bbb8..1c72e511943 100644 --- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts +++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts @@ -135,6 +135,15 @@ describe("formatAssistantErrorText", () => { ); }); + it("does not rewrite generic OAuth refresh failures without token-reuse signal", () => { + const msg = makeAssistantError( + "OAuth token refresh failed for openai-codex: request timed out while contacting auth endpoint", + ); + expect(formatAssistantErrorText(msg, { provider: "openai-codex" })).toBe( + "LLM request timed out.", + ); + }); + it("returns a friendly message for empty stream chunk errors", () => { const msg = makeAssistantError("request ended without sending any chunks"); expect(formatAssistantErrorText(msg)).toBe("LLM request timed out."); diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 3d6aaee99a6..93a1944af65 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -624,6 +624,11 @@ describe("classifyFailoverReason", () => { 'OAuth token refresh failed for openai-codex: 401 {"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","code":"refresh_token_reused"}}', ), ).toBe("auth"); + expect( + classifyFailoverReason( + "OAuth token refresh failed for openai-codex: request timed out while contacting auth endpoint", + ), + ).toBe("timeout"); expect( classifyFailoverReason("model_cooldown: All credentials for model gpt-5 are cooling down"), ).toBe("rate_limit"); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index a564fec6a67..28ff55bc7c2 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -48,11 +48,14 @@ function isOauthRefreshReauthRequiredMessage(raw: string): boolean { return false; } const lower = raw.toLowerCase(); - return ( + const hasRefreshFailureContext = lower.includes("oauth token refresh failed") || - lower.includes("refresh_token_reused") || - lower.includes("refresh token has already been used") - ); + lower.includes("token refresh failed") || + lower.includes("refresh token"); + const hasTokenReuseSignal = + lower.includes("refresh_token_reused") || lower.includes("refresh token has already been used"); + + return hasRefreshFailureContext && hasTokenReuseSignal; } function formatOauthRefreshReauthCopy(provider?: string): string { From 80c72482068f9fba194146d33c3d498a2acf08ec Mon Sep 17 00:00:00 2001 From: Isaac the Kaylon Date: Wed, 11 Mar 2026 13:30:48 +0000 Subject: [PATCH 4/5] fix(models): skip implicit local ollama discovery when remote ollama api provider is configured --- .../models-config.providers.matrix.test.ts | 24 ++++++++++ src/agents/models-config.providers.ts | 46 ++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/agents/models-config.providers.matrix.test.ts b/src/agents/models-config.providers.matrix.test.ts index 942cb68ab35..81e135625ae 100644 --- a/src/agents/models-config.providers.matrix.test.ts +++ b/src/agents/models-config.providers.matrix.test.ts @@ -154,6 +154,30 @@ const MATRIX_CASES: MatrixCase[] = [ expect(providers?.ollama?.models).toHaveLength(1); }, }, + { + name: "skip implicit local ollama when explicit remote ollama-api provider exists", + env: { OLLAMA_API_KEY: "test-ollama-key" }, // pragma: allowlist secret + explicitProviders: { + "ollama-cloud": { + baseUrl: "https://ollama.com", + api: "ollama", + models: [ + { + id: "kimi-k2.5", + name: "Kimi K2.5", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ], + }, + }, + assertProviders(providers) { + expect(providers?.ollama).toBeUndefined(); + }, + }, ]; describe("implicit provider resolution matrix", () => { diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index c63ed6865a8..207a94ca460 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -615,6 +615,45 @@ async function resolveCloudflareAiGatewayImplicitProvider( return undefined; } +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" || + normalized === "[::1]" + ); +} + +function hasExplicitRemoteOllamaApiProvider( + explicitProviders: ImplicitProviderContext["explicitProviders"], +): boolean { + if (!explicitProviders) { + return false; + } + for (const [providerId, provider] of Object.entries(explicitProviders)) { + if (!provider || providerId === "ollama") { + continue; + } + if ((provider.api ?? "").trim().toLowerCase() !== "ollama") { + continue; + } + const baseUrl = (provider.baseUrl ?? "").trim(); + if (!baseUrl) { + continue; + } + try { + const parsed = new URL(baseUrl); + if (!isLoopbackHost(parsed.hostname)) { + return true; + } + } catch { + // Ignore malformed explicit base URLs here; validation happens elsewhere. + } + } + return false; +} + async function resolveOllamaImplicitProvider( ctx: ImplicitProviderContext, ): Promise | undefined> { @@ -635,8 +674,13 @@ async function resolveOllamaImplicitProvider( const ollamaBaseUrl = explicitOllama?.baseUrl; const hasExplicitOllamaConfig = Boolean(explicitOllama); + const hasRemoteOllamaApiProvider = hasExplicitRemoteOllamaApiProvider(ctx.explicitProviders); + if (!hasExplicitOllamaConfig && hasRemoteOllamaApiProvider) { + return undefined; + } + const ollamaProvider = await buildOllamaProvider(ollamaBaseUrl, { - quiet: !ollamaKey && !hasExplicitOllamaConfig, + quiet: !hasExplicitOllamaConfig, }); if (ollamaProvider.models.length === 0 && !ollamaKey && !explicitOllama?.apiKey) { return undefined; From b2d4414b0512a0d964791c6a853904d68b186a9a Mon Sep 17 00:00:00 2001 From: Isaac the Kaylon Date: Wed, 11 Mar 2026 13:40:54 +0000 Subject: [PATCH 5/5] Revert "fix(models): skip implicit local ollama discovery when remote ollama api provider is configured" This reverts commit 80c72482068f9fba194146d33c3d498a2acf08ec. --- .../models-config.providers.matrix.test.ts | 24 ---------- src/agents/models-config.providers.ts | 46 +------------------ 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/src/agents/models-config.providers.matrix.test.ts b/src/agents/models-config.providers.matrix.test.ts index 81e135625ae..942cb68ab35 100644 --- a/src/agents/models-config.providers.matrix.test.ts +++ b/src/agents/models-config.providers.matrix.test.ts @@ -154,30 +154,6 @@ const MATRIX_CASES: MatrixCase[] = [ expect(providers?.ollama?.models).toHaveLength(1); }, }, - { - name: "skip implicit local ollama when explicit remote ollama-api provider exists", - env: { OLLAMA_API_KEY: "test-ollama-key" }, // pragma: allowlist secret - explicitProviders: { - "ollama-cloud": { - baseUrl: "https://ollama.com", - api: "ollama", - models: [ - { - id: "kimi-k2.5", - name: "Kimi K2.5", - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 8192, - }, - ], - }, - }, - assertProviders(providers) { - expect(providers?.ollama).toBeUndefined(); - }, - }, ]; describe("implicit provider resolution matrix", () => { diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 207a94ca460..c63ed6865a8 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -615,45 +615,6 @@ async function resolveCloudflareAiGatewayImplicitProvider( return undefined; } -function isLoopbackHost(hostname: string): boolean { - const normalized = hostname.trim().toLowerCase(); - return ( - normalized === "localhost" || - normalized === "127.0.0.1" || - normalized === "::1" || - normalized === "[::1]" - ); -} - -function hasExplicitRemoteOllamaApiProvider( - explicitProviders: ImplicitProviderContext["explicitProviders"], -): boolean { - if (!explicitProviders) { - return false; - } - for (const [providerId, provider] of Object.entries(explicitProviders)) { - if (!provider || providerId === "ollama") { - continue; - } - if ((provider.api ?? "").trim().toLowerCase() !== "ollama") { - continue; - } - const baseUrl = (provider.baseUrl ?? "").trim(); - if (!baseUrl) { - continue; - } - try { - const parsed = new URL(baseUrl); - if (!isLoopbackHost(parsed.hostname)) { - return true; - } - } catch { - // Ignore malformed explicit base URLs here; validation happens elsewhere. - } - } - return false; -} - async function resolveOllamaImplicitProvider( ctx: ImplicitProviderContext, ): Promise | undefined> { @@ -674,13 +635,8 @@ async function resolveOllamaImplicitProvider( const ollamaBaseUrl = explicitOllama?.baseUrl; const hasExplicitOllamaConfig = Boolean(explicitOllama); - const hasRemoteOllamaApiProvider = hasExplicitRemoteOllamaApiProvider(ctx.explicitProviders); - if (!hasExplicitOllamaConfig && hasRemoteOllamaApiProvider) { - return undefined; - } - const ollamaProvider = await buildOllamaProvider(ollamaBaseUrl, { - quiet: !hasExplicitOllamaConfig, + quiet: !ollamaKey && !hasExplicitOllamaConfig, }); if (ollamaProvider.models.length === 0 && !ollamaKey && !explicitOllama?.apiKey) { return undefined;