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>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-20 10:59:13 -03:00
committed by GitHub
parent 9112056a2b
commit 94f474b943
3 changed files with 58 additions and 4 deletions

View File

@@ -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)
---

View File

@@ -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"

View File

@@ -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");
});