fix(sse): proactively refresh Grok Build OAuth token before dispatch (#7610) (#7715)

GrokCliExecutor.execute() dispatches via raw https.request (nativePost)
instead of the shared fetch path, so it never inherited (nor delegated to)
BaseExecutor.execute()'s proactive-refresh gate the way codex.ts does via
super.execute(). The only refresh that ever fired was the reactive one on a
401/403 from upstream — the rotating xAI refresh_token idled until real
expiry, matching the "unusable within minutes, must delete/re-add" report.

Wires in the same needsRefresh()/refreshCredentials() gate, using
runWithOnPersist + isUnrecoverableRefreshError to keep the [refresh +
persist] atomic under the same per-connection mutex Codex/Claude rely on
for rotating refresh tokens (base.ts:592-644).

Also fixes the smaller, separate bug #2 from the same report: grok-cli was
absent from OAUTH_TEST_CONFIG in the connection-test route, so "Test
Connection" always reported "Provider test not supported" regardless of
token health. Added a checkExpiry entry (same pattern as qwen/cline/
kilocode — Grok Build's proxy doesn't expose a lightweight probe endpoint
with the cli-specific headers this shared prober sends). Extracted
OAUTH_TEST_CONFIG into its own module (oauthTestConfig.ts) so the new entry
doesn't grow the frozen route.ts past its file-size cap.

Bug #3 (no browser/device-code login for Grok Build) and bug #4 (quota
display) from the same issue are feature gaps, not regressions — left as
follow-ups per the triage plan-file.

Refs #7610
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-19 02:34:49 -03:00
committed by GitHub
parent 69bbcafcb4
commit 313cbefda4
6 changed files with 288 additions and 124 deletions

View File

@@ -0,0 +1 @@
- fix(sse): proactively refresh Grok Build's expiring OAuth token before dispatch, and add it to the connection-test config so "Test Connection" no longer reports "unsupported" (#7610)

View File

@@ -16,6 +16,7 @@ import {
import { PROVIDERS } from "../config/constants.ts";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import { resolveProxyForRequest } from "../utils/proxyFetch.ts";
import { runWithOnPersist, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts";
import https from "node:https";
import { HttpsProxyAgent } from "https-proxy-agent";
@@ -76,17 +77,78 @@ export class GrokCliExecutor extends BaseExecutor {
}
async execute(input: ExecuteInput) {
const { model, body, stream, credentials, signal } = input;
const { model, body, stream, credentials, signal, log, onCredentialsRefreshed } = input;
const url = this.buildUrl(model, stream, 0, credentials);
const headers = this.buildHeaders(credentials, stream);
const transformedBody = this.transformRequest(model, body, stream, credentials);
// #7610: unlike BaseExecutor.execute() (which most executors inherit or
// delegate to via super.execute()), this executor talks upstream via raw
// https.request() (nativePost) instead of the shared fetch path, so it
// never picked up the base class's proactive refresh gate. Without it,
// xAI's rotating refresh_token idled until real expiry — the only refresh
// that fired was the reactive one on a 401/403 from upstream — matching
// the "unusable within minutes" report. Apply the same gate here.
const activeCredentials = await this.applyProactiveRefresh(
credentials,
log,
onCredentialsRefreshed
);
const url = this.buildUrl(model, stream, 0, activeCredentials);
const headers = this.buildHeaders(activeCredentials, stream);
const transformedBody = this.transformRequest(model, body, stream, activeCredentials);
const bodyStr = JSON.stringify(transformedBody);
const response = await this.nativePost(url, headers, bodyStr, signal);
return { response, url, headers, transformedBody };
}
/**
* Proactive-refresh gate mirroring BaseExecutor.execute()'s (base.ts:599-685),
* scoped to grok-cli's single-URL nativePost dispatch (no fallback-URL retry
* loop to thread through). xAI uses rotating refresh tokens (same family as
* Codex/Claude) — `runWithOnPersist` keeps the [refresh + persist] atomic
* under the same per-connection mutex `getAccessToken` uses, and
* `isUnrecoverableRefreshError` keeps a reused/invalid sentinel from being
* spread into the outgoing credentials — see base.ts:622-673 for the full
* regression history this mirrors.
*/
private async applyProactiveRefresh(
credentials: ProviderCredentials,
log?: ExecutorLog | null,
onCredentialsRefreshed?: ExecuteInput["onCredentialsRefreshed"]
): Promise<ProviderCredentials> {
if (!this.needsRefresh(credentials)) return credentials;
try {
let persistRan = false;
const onPersist = onCredentialsRefreshed
? async (refreshResult: Record<string, unknown>) => {
persistRan = true;
await onCredentialsRefreshed(refreshResult as Partial<ProviderCredentials>);
}
: null;
const refreshed = await runWithOnPersist(onPersist, () =>
this.refreshCredentials(credentials, log || null)
);
if (!refreshed || isUnrecoverableRefreshError(refreshed)) {
return credentials;
}
const merged = { ...credentials, ...refreshed };
if (onCredentialsRefreshed && !persistRan) {
await onCredentialsRefreshed(refreshed);
}
return merged;
} catch (error) {
log?.error?.(
"TOKEN",
`Credential refresh failed for ${this.provider}: ${error instanceof Error ? error.message : String(error)}`
);
return credentials;
}
}
async refreshCredentials(
credentials: ProviderCredentials,
log?: ExecutorLog | null

View File

@@ -0,0 +1,129 @@
import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab";
// OAuth provider test endpoints. Extracted from route.ts (#7610) so adding a
// provider entry doesn't grow the frozen route.ts file past its check-file-size
// cap — this module carries no logic of its own beyond the GitLab URL builder.
export const OAUTH_TEST_CONFIG = {
claude: {
// Claude doesn't have userinfo, we verify token exists and not expired
checkExpiry: true,
refreshable: true,
},
codex: {
// Port of decolua/9router#347: probe the real Codex /responses endpoint instead
// of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens
// (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403.
// Hitting the actual endpoint with a minimal invalid body returns 400 when
// auth is accepted (the body is the reason for the failure) and 401/403 when
// the token is bad. That is a real auth signal — checkExpiry alone could not
// distinguish a revoked-but-not-yet-expired token from a working one.
url: "https://chatgpt.com/backend-api/codex/responses",
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: {
"Content-Type": "application/json",
originator: "codex-cli",
"User-Agent": "codex-cli/1.0.18 (macOS; arm64)",
},
// Minimal invalid body — triggers a fast 400 without consuming quota.
// #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a
// codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason
// (unsupported model, not "auth ok, body invalid") — collapsing the auth signal
// so a bad token looks the same as a good one. "gpt-5.5" is served for
// ChatGPT sessions; `input: []` still yields the intended 400.
body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }),
// 400 = bad request, but auth was accepted; only 401/403 means the token is bad.
acceptStatuses: [400],
refreshable: true,
},
antigravity: {
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
xai: {
url: "https://api.x.ai/v1/chat/completions",
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "grok-4.3",
messages: [{ role: "user", content: "ping" }],
max_tokens: 1,
stream: false,
reasoning: { effort: "high" },
}),
refreshable: true,
},
github: {
url: "https://api.github.com/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
},
"gitlab-duo": {
getUrl: (connection: any) =>
buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData))
.directAccessUrl,
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
qwen: {
// DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues.
// Use checkExpiry instead — actual connectivity is validated via real requests.
checkExpiry: true,
refreshable: true,
},
cursor: {
checkExpiry: true,
},
"kimi-coding": {
checkExpiry: true,
refreshable: true,
},
kilocode: {
// Kilo OAuth does not expose a stable user-info endpoint in all environments.
// Validate using token presence/expiry as a lightweight auth check.
checkExpiry: true,
},
cline: {
// Cline's /api/v1/models endpoint frequently returns stale auth errors even
// with fresh tokens. Use checkExpiry instead — actual connectivity is validated
// via real requests.
checkExpiry: true,
refreshable: true,
},
kiro: {
checkExpiry: true,
refreshable: true,
},
"amazon-q": {
checkExpiry: true,
refreshable: true,
},
"codebuddy-cn": {
// Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port —
// validate auth via token presence + refresh path. Live connectivity is
// verified through real /v2/chat/completions traffic.
checkExpiry: true,
refreshable: true,
},
"grok-cli": {
// #7610: was entirely absent from OAUTH_TEST_CONFIG, so "Test Connection"
// always fell through to the generic "Provider test not supported" branch
// below. Grok Build's cli-chat-proxy endpoint doesn't expose a lightweight
// userinfo probe, and it enforces cli-specific headers (see
// GrokCliExecutor.buildHeaders) that this shared prober doesn't send — so
// mirror qwen/cline/kilocode's checkExpiry pattern instead of a live probe.
// Real connectivity is still validated on every chat/completions request.
checkExpiry: true,
refreshable: true,
},
};

View File

@@ -17,134 +17,16 @@ import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer
import { saveCallLog } from "@/lib/usageDb";
import { logProxyEvent } from "@/lib/proxyLogger";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import {
buildGitLabOAuthEndpoints,
isGitLabDirectAccessDisabled,
resolveGitLabOAuthBaseUrl,
} from "@/lib/oauth/gitlab";
import { isGitLabDirectAccessDisabled } from "@/lib/oauth/gitlab";
import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
import { OAUTH_TEST_CONFIG } from "./oauthTestConfig";
// Bound the OAuth probe so a hung upstream can't block the connection-test queue
// forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey.
const OAUTH_TEST_TIMEOUT_MS = 30_000;
// OAuth provider test endpoints
const OAUTH_TEST_CONFIG = {
claude: {
// Claude doesn't have userinfo, we verify token exists and not expired
checkExpiry: true,
refreshable: true,
},
codex: {
// Port of decolua/9router#347: probe the real Codex /responses endpoint instead
// of relying on `checkExpiry`. Codex OAuth tokens are ChatGPT session tokens
// (not OpenAI API keys) — api.openai.com/v1/models rejects them with 403.
// Hitting the actual endpoint with a minimal invalid body returns 400 when
// auth is accepted (the body is the reason for the failure) and 401/403 when
// the token is bad. That is a real auth signal — checkExpiry alone could not
// distinguish a revoked-but-not-yet-expired token from a working one.
url: "https://chatgpt.com/backend-api/codex/responses",
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: {
"Content-Type": "application/json",
originator: "codex-cli",
"User-Agent": "codex-cli/1.0.18 (macOS; arm64)",
},
// Minimal invalid body — triggers a fast 400 without consuming quota.
// #7521: probe with a ChatGPT-account-supported model. "gpt-5.3-codex" is a
// codex-only id that ChatGPT accounts reject with a 400 for the WRONG reason
// (unsupported model, not "auth ok, body invalid") — collapsing the auth signal
// so a bad token looks the same as a good one. "gpt-5.5" is served for
// ChatGPT sessions; `input: []` still yields the intended 400.
body: JSON.stringify({ model: "gpt-5.5", input: [], stream: false, store: false }),
// 400 = bad request, but auth was accepted; only 401/403 means the token is bad.
acceptStatuses: [400],
refreshable: true,
},
antigravity: {
url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
xai: {
url: "https://api.x.ai/v1/chat/completions",
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "grok-4.3",
messages: [{ role: "user", content: "ping" }],
max_tokens: 1,
stream: false,
reasoning: { effort: "high" },
}),
refreshable: true,
},
github: {
url: "https://api.github.com/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
},
"gitlab-duo": {
getUrl: (connection: any) =>
buildGitLabOAuthEndpoints(resolveGitLabOAuthBaseUrl(connection?.providerSpecificData))
.directAccessUrl,
method: "POST",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
qwen: {
// DashScope (previously portal.qwen.ai) /v1/models might return 404 or auth issues.
// Use checkExpiry instead — actual connectivity is validated via real requests.
checkExpiry: true,
refreshable: true,
},
cursor: {
checkExpiry: true,
},
"kimi-coding": {
checkExpiry: true,
refreshable: true,
},
kilocode: {
// Kilo OAuth does not expose a stable user-info endpoint in all environments.
// Validate using token presence/expiry as a lightweight auth check.
checkExpiry: true,
},
cline: {
// Cline's /api/v1/models endpoint frequently returns stale auth errors even
// with fresh tokens. Use checkExpiry instead — actual connectivity is validated
// via real requests.
checkExpiry: true,
refreshable: true,
},
kiro: {
checkExpiry: true,
refreshable: true,
},
"amazon-q": {
checkExpiry: true,
refreshable: true,
},
"codebuddy-cn": {
// Upstream test endpoint mirrors "tokenExists: true" from the CodeBuddy port —
// validate auth via token presence + refresh path. Live connectivity is
// verified through real /v2/chat/completions traffic.
checkExpiry: true,
refreshable: true,
},
};
import { CLI_RUNTIME_PROVIDER_MAP } from "./cliRuntimeProviderMap";
/** POST body is optional; when present, only known fields are validated. */

View File

@@ -0,0 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
// #7610 bug #2: `grok-cli` was absent from OAUTH_TEST_CONFIG in
// src/app/api/providers/[id]/test/route.ts, so "Test Connection" for a Grok
// Build (OAuth) connection always fell through to the generic
// "Provider test not supported" branch, regardless of whether the token was
// actually healthy.
const { testOAuthConnection } = await import("../../src/app/api/providers/[id]/test/route.ts");
test("#7610: grok-cli OAuth connection test is no longer 'unsupported'", async () => {
const connection = {
provider: "grok-cli",
accessToken: "healthy-access-token",
refreshToken: "healthy-refresh-token",
// Far in the future — not expired, so this exercises the checkExpiry
// "still valid" branch rather than the refresh path.
tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(),
};
const result = await testOAuthConnection(connection);
assert.notEqual(result.diagnosis?.type, "unsupported");
assert.notEqual(result.error, "Provider test not supported");
assert.equal(result.valid, true);
});

View File

@@ -0,0 +1,64 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { GrokCliExecutor } from "../../open-sse/executors/grok-cli.ts";
import type { ExecuteInput, ExecutorLog, ProviderCredentials } from "../../open-sse/executors/base.ts";
type TestableGrokCliExecutor = {
execute: (input: ExecuteInput) => Promise<{ response: Response }>;
refreshCredentials: (
credentials: ProviderCredentials,
log?: ExecutorLog | null
) => Promise<Partial<ProviderCredentials> | null>;
nativePost: (
url: string,
headers: Record<string, string>,
bodyStr: string,
signal?: AbortSignal | null
) => Promise<Response>;
};
test("GrokCliExecutor.execute() proactively refreshes an expired access token (#7610)", async () => {
const executor = new GrokCliExecutor() as unknown as TestableGrokCliExecutor;
// Stub the real network call (nativeHttpsPost → auth.x.ai) so the test never
// touches the network — only the wiring (does execute() call
// refreshCredentials() at all, and does the refreshed token reach the
// outgoing Authorization header) is under test here.
let refreshCalled = false;
executor.refreshCredentials = async () => {
refreshCalled = true;
return {
accessToken: "FRESH_ACCESS_TOKEN",
refreshToken: "rotated-refresh-token",
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
};
};
let capturedHeaders: Record<string, string> | null = null;
executor.nativePost = async (_url, headers) => {
capturedHeaders = headers;
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
const expiredAt = new Date(Date.now() - 60_000).toISOString();
const credentials: ProviderCredentials = {
accessToken: "STALE_ACCESS_TOKEN",
refreshToken: "valid-refresh-token",
expiresAt: expiredAt,
};
await executor.execute({
model: "grok-composer-2.5-fast",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials,
} as ExecuteInput);
assert.equal(
refreshCalled,
true,
"expected GrokCliExecutor.execute() to proactively call refreshCredentials()"
);
assert.notEqual(capturedHeaders?.["Authorization"], "Bearer STALE_ACCESS_TOKEN");
assert.equal(capturedHeaders?.["Authorization"], "Bearer FRESH_ACCESS_TOKEN");
});