diff --git a/changelog.d/fixes/7610-grok-proactive-refresh.md b/changelog.d/fixes/7610-grok-proactive-refresh.md new file mode 100644 index 0000000000..c6525e0aeb --- /dev/null +++ b/changelog.d/fixes/7610-grok-proactive-refresh.md @@ -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) diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts index 15f2e6e920..c28813c069 100644 --- a/open-sse/executors/grok-cli.ts +++ b/open-sse/executors/grok-cli.ts @@ -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 { + if (!this.needsRefresh(credentials)) return credentials; + + try { + let persistRan = false; + const onPersist = onCredentialsRefreshed + ? async (refreshResult: Record) => { + persistRan = true; + await onCredentialsRefreshed(refreshResult as Partial); + } + : 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 diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts new file mode 100644 index 0000000000..22c4bdc601 --- /dev/null +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -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, + }, +}; diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index c7d78fb2d8..39066bc4fe 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -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. */ diff --git a/tests/unit/grok-cli-oauth-test-supported-7610.test.ts b/tests/unit/grok-cli-oauth-test-supported-7610.test.ts new file mode 100644 index 0000000000..b57d2a0a92 --- /dev/null +++ b/tests/unit/grok-cli-oauth-test-supported-7610.test.ts @@ -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); +}); diff --git a/tests/unit/grok-cli-proactive-refresh-7610.test.ts b/tests/unit/grok-cli-proactive-refresh-7610.test.ts new file mode 100644 index 0000000000..fd6b4750fa --- /dev/null +++ b/tests/unit/grok-cli-proactive-refresh-7610.test.ts @@ -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 | null>; + nativePost: ( + url: string, + headers: Record, + bodyStr: string, + signal?: AbortSignal | null + ) => Promise; +}; + +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 | 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"); +});