mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
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:
committed by
GitHub
parent
9112056a2b
commit
94f474b943
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
23
tests/unit/provider-test-account-deactivated.test.ts
Normal file
23
tests/unit/provider-test-account-deactivated.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user