From 94f474b943b6fb638ce671dbb80ec1acdcebe257 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:59:13 -0300 Subject: [PATCH] fix(providers): label a deactivated account distinctly from a revoked token (#4353) A Codex connection whose OAuth refresh is fully healthy but whose ChatGPT account has been deactivated by the provider gets a 401 from the upstream API. The connection test labeled that the same as a bad credential ("Token invalid or revoked" -> upstream_auth_error), so an operator could not tell a deactivated account from a revoked token. The test now reads the 401/403 body and, when it indicates account deactivation, classifies it as account_deactivated (which the dashboard already renders as "Account Deactivated"); a plain auth 401 is unchanged. Reported-by: ntdung6868 (https://github.com/decolua/9router/issues/1444) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> --- CHANGELOG.md | 1 + src/app/api/providers/[id]/test/route.ts | 38 +++++++++++++++++-- .../provider-test-account-deactivated.test.ts | 23 +++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 tests/unit/provider-test-account-deactivated.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 985a6582ef..33b9f7da80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(translator): accept AI SDK-style `{ type: "image", image: "data:…" }` content parts** — several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style part where `image` is a bare data-URL **string** was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI→Claude, OpenAI→Kiro and OpenAI→Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude `{source:{type:"base64"}}`, Kiro `images[].source.bytes`, Gemini `inlineData`). (thanks @mugnimaestra) - **fix(dashboard): a disabled connection's last error is now visible** — the provider card's error badge counts a disabled connection (`isActive === false`) that has an error (its effective status is still error/expired/unavailable), but the connection row hid the `lastError` text for disabled rows — so the operator saw the error count without being able to see what failed. The row now shows the error text whenever there is one, regardless of the active toggle. (thanks @ntdung6868) - **fix(providers): the "Test Connection One-by-One" OAuth probe can no longer hang the queue forever** — the OAuth connection-test path called bare `fetch(url, { method, headers })` with no `AbortController`/signal/timeout, so when a provider's probe endpoint accepted the socket but never responded, the awaited fetch never settled and the one-by-one test queue stalled indefinitely (the API-key path was already bounded via `validateProviderApiKey`'s `timeoutMs`). Both the initial probe and the post-refresh retry are now bounded with `AbortSignal.timeout(30s)` — matching the API-key path's 30s budget — and a timed-out probe resolves as a failure with a clear `Test timed out after 30s` message in the same shape as every other test error. (thanks @ntdung6868) +- **fix(providers): a deactivated account is labeled distinctly from a revoked token** — a Codex connection whose OAuth refresh is fully healthy but whose ChatGPT account has been deactivated by the provider gets a `401` from the upstream API. The connection test labeled that the same as a bad credential (`Token invalid or revoked` → `upstream_auth_error`), so the operator couldn't tell a deactivated account from a revoked token. The test now reads the `401`/`403` body and, when it indicates account deactivation, classifies it as `account_deactivated` — which the dashboard already renders as "Account Deactivated". A plain auth `401` is unchanged. (thanks @ntdung6868) --- diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index e52b13b210..7acdd77614 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -135,7 +135,19 @@ function makeDiagnosis( }; } -function classifyFailure({ +/** + * A provider/account that the upstream has deactivated (vs. a revoked/expired token). + * #1444: a Codex account can have a perfectly healthy OAuth refresh while its ChatGPT + * account is deactivated, in which case the API returns 401 — mislabeling that as + * "Token invalid or revoked" hides the real cause. Mirrors the deactivation phrases the + * account-fallback classifier already trusts. + */ +function isAccountDeactivatedMessage(text: string): boolean { + const n = (text || "").toLowerCase(); + return n.includes("account_deactivated") || (n.includes("deactivat") && n.includes("account")); +} + +export function classifyFailure({ error, statusCode = null, refreshFailed = false, @@ -158,6 +170,13 @@ function classifyFailure({ return makeDiagnosis("token_refresh_failed", "oauth", message, "refresh_failed"); } + // #1444: a deactivated account is distinct from a revoked/expired token — surface it + // as account_deactivated (which the dashboard renders as "Account Deactivated") before + // the generic 401/403 branch below would mark it "upstream_auth_error". + if (isAccountDeactivatedMessage(normalized)) { + return makeDiagnosis("account_deactivated", "account", message, "account_deactivated"); + } + if (numericStatus === 401 || numericStatus === 403) { return makeDiagnosis("upstream_auth_error", "upstream", message, String(numericStatus)); } @@ -566,7 +585,12 @@ export async function testOAuthConnection( }; } - const error = `API returned ${retryRes.status} after token refresh`; + // #1444: a fresh token that still gets a 401 because the account itself was + // deactivated must be labeled account_deactivated, not a generic auth error. + const retryBody = await retryRes.text().catch(() => ""); + const error = isAccountDeactivatedMessage(retryBody) + ? "Account deactivated by the provider" + : `API returned ${retryRes.status} after token refresh`; return { valid: false, error, @@ -585,8 +609,14 @@ export async function testOAuthConnection( }; } - const error = - res.status === 401 + // #1444: read a 401/403 body so a deactivated account is labeled distinctly from a + // revoked token. (The body is unread here for non-gitlab providers; the guard keeps + // it safe if it was already consumed.) + const bodyText = + res.status === 401 || res.status === 403 ? await res.text().catch(() => "") : ""; + const error = isAccountDeactivatedMessage(bodyText) + ? "Account deactivated by the provider" + : res.status === 401 ? "Token invalid or revoked" : res.status === 403 ? "Access denied" diff --git a/tests/unit/provider-test-account-deactivated.test.ts b/tests/unit/provider-test-account-deactivated.test.ts new file mode 100644 index 0000000000..704a373d26 --- /dev/null +++ b/tests/unit/provider-test-account-deactivated.test.ts @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for port-from-9router#1444: a Codex connection whose OAuth refresh is +// fully healthy but whose ChatGPT account has been deactivated by OpenAI returns a +// 401 from the Codex API. The connection test labeled that the same as a revoked +// token ("Token invalid or revoked" → upstream_auth_error), so the operator couldn't +// tell a deactivated account from a bad token. A deactivation message now classifies +// as `account_deactivated`, which the dashboard already renders as "Account Deactivated". +const { classifyFailure } = await import("../../src/app/api/providers/[id]/test/route.ts"); + +test("#1444: a deactivation message classifies as account_deactivated", () => { + const d = classifyFailure({ + error: "Your account has been deactivated. Please contact support.", + statusCode: 401, + }); + assert.equal(d.type, "account_deactivated"); +}); + +test("#1444: a plain 401 still classifies as upstream_auth_error", () => { + const d = classifyFailure({ error: "Token invalid or revoked", statusCode: 401 }); + assert.equal(d.type, "upstream_auth_error"); +});