Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down
38 changes: 34 additions & 4 deletions src/app/api/providers/[id]/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));
}
Expand Down Expand Up @@ -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,
Expand All @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/provider-test-account-deactivated.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading