diff --git a/open-sse/executors/grok-cli.js b/open-sse/executors/grok-cli.js new file mode 100644 index 00000000..00684d56 --- /dev/null +++ b/open-sse/executors/grok-cli.js @@ -0,0 +1,397 @@ +import crypto from "node:crypto"; +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { + refreshProviderCredentials, + shouldRefreshCredentials, +} from "../services/oauthCredentialManager.js"; +import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; +import { getModelUpstreamId } from "../config/providerModels.js"; +import { resolveSessionId } from "../utils/sessionManager.js"; +import { getConsistentMachineId } from "../shared/machineId.js"; + +// Server-generated item id prefixes that /responses cannot resolve when store=false +const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; + +// Hosted tool types executed server-side by Grok CLI backend +const HOSTED_TOOL_TYPES = new Set([ + "web_search", + "x_search", + "web_search_preview", + "file_search", + "image_generation", + "code_interpreter", + "mcp", + "local_shell", +]); + +// Fields accepted by cli-chat-proxy Responses API (mirrors Codex allowlist + Grok extras) +const RESPONSES_API_ALLOWLIST = new Set([ + "model", + "input", + "instructions", + "tools", + "tool_choice", + "stream", + "store", + "reasoning", + "include", + "temperature", + "top_p", + "max_output_tokens", + "parallel_tool_calls", + "text", + "metadata", + "prompt_cache_key", +]); + +const EFFORT_LEVELS = ["low", "medium", "high"]; + +// Per-session last turn index so multi-turn headers never go backwards within this process +const sessionTurnStore = new Map(); + +/** + * Count user turns in a Responses `input` array. + * Official CLI sets x-grok-turn-idx to the 1-based conversation turn (≈ user messages). + * HAR: first chat turn → "1". + */ +export function countGrokCliUserTurns(input) { + if (!Array.isArray(input)) return 1; + let n = 0; + for (const item of input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const type = typeof item.type === "string" ? item.type : ""; + // Responses message items (type omitted or "message") with role user + if (item.role === "user" && (!type || type === "message")) n += 1; + } + return Math.max(1, n); +} + +/** + * Resolve monotonic turn index for a session. + * Prefers user-message count from the payload (full history clients), but never + * decreases vs the last index observed for the same sessionId in this process. + */ +export function resolveGrokCliTurnIdx(sessionId, input) { + const fromInput = countGrokCliUserTurns(input); + if (!sessionId) return fromInput; + const prev = sessionTurnStore.get(sessionId) || 0; + const turn = Math.max(fromInput, prev); + sessionTurnStore.set(sessionId, turn); + return turn; +} + +/** Test helper — clear in-memory turn counters */ +export function _resetGrokCliTurnStore() { + sessionTurnStore.clear(); +} + +function stripStoredItemReferences(body) { + if (!Array.isArray(body.input)) return; + body.input = body.input.filter((item) => { + if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false; + if (item && typeof item === "object" && !Array.isArray(item)) { + if (item.type === "item_reference") return false; + if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id; + } + return true; + }); +} + +/** + * Flatten Chat Completions tool shape → Responses flat format. + * Keep hosted tools (web_search / x_search) passthrough. + */ +function normalizeGrokCliTools(body) { + if (!Array.isArray(body.tools)) return; + const validNames = new Set(); + body.tools = body.tools.filter((tool) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + const type = typeof tool.type === "string" ? tool.type : ""; + + if (type !== "function") { + // Hosted tools: { type: "web_search" } / { type: "x_search" } + if (HOSTED_TOOL_TYPES.has(type)) return true; + // Nested function shape without type + if (!type && tool.function) { + // fall through to function flatten below + } else if (!type || typeof tool.name === "string") { + // treat as bare function if name present + } else { + return false; + } + } + + const isFunction = + type === "function" || type === "" || tool.function || typeof tool.name === "string"; + if (!isFunction || HOSTED_TOOL_TYPES.has(type)) { + return HOSTED_TOOL_TYPES.has(type); + } + + const fn = + tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) + ? tool.function + : null; + const rawName = + typeof tool.name === "string" ? tool.name : typeof fn?.name === "string" ? fn.name : ""; + const name = rawName.trim(); + if (!name) return false; + + const description = + typeof tool.description === "string" + ? tool.description + : typeof fn?.description === "string" + ? fn.description + : ""; + const parameters = + tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) + ? tool.parameters + : fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) + ? fn.parameters + : { type: "object", properties: {} }; + + for (const k of Object.keys(tool)) delete tool[k]; + tool.type = "function"; + tool.name = name.slice(0, 128); + if (description) tool.description = description; + tool.parameters = parameters; + validNames.add(name); + return true; + }); + + if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) { + if (body.tool_choice.type === "function") { + const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : ""; + if (!n || !validNames.has(n)) delete body.tool_choice; + } + } +} + +function resolveEffortFromModel(modelId) { + if (!modelId || typeof modelId !== "string") return null; + for (const level of EFFORT_LEVELS) { + if (modelId.endsWith(`-${level}`)) return level; + } + return null; +} + +/** + * Grok CLI Executor — OpenAI Responses API on cli-chat-proxy.grok.com + * Auth: OAuth device-code access token (xai-grok-cli). + */ +export class GrokCliExecutor extends BaseExecutor { + constructor() { + super("grok-cli", PROVIDERS["grok-cli"]); + this._currentSessionId = null; + this._currentReqId = null; + this._currentTurnIdx = 1; + this._agentId = null; + } + + buildUrl() { + return this.config.baseUrl; + } + + async refreshCredentials(credentials, log) { + if (!credentials?.refreshToken) return null; + return refreshProviderCredentials("grok-cli", credentials, log); + } + + needsRefresh(credentials) { + return shouldRefreshCredentials("grok-cli", credentials); + } + + buildHeaders(credentials, stream = true) { + const headers = super.buildHeaders(credentials, stream); + + // Static fingerprint from registry + const staticHeaders = this.config.headers || {}; + for (const [k, v] of Object.entries(staticHeaders)) { + if (v != null && headers[k] === undefined) headers[k] = v; + } + + // Ensure token-auth marker is present even if headers map was overridden + headers["x-xai-token-auth"] = this.config.tokenAuth || "xai-grok-cli"; + headers["x-grok-client-identifier"] = + this.config.clientIdentifier || headers["x-grok-client-identifier"] || "grok-pager"; + headers["x-grok-client-version"] = + this.config.clientVersion || headers["x-grok-client-version"] || "0.2.93"; + headers["x-authenticateresponse"] = "authenticate-response"; + + const sessionId = this._currentSessionId || credentials?.connectionId || crypto.randomUUID(); + const reqId = this._currentReqId || crypto.randomUUID(); + headers["x-grok-session-id"] = sessionId; + // CLI uses the same id for conv + session on chat turns + headers["x-grok-conv-id"] = sessionId; + headers["x-grok-req-id"] = reqId; + headers["x-grok-turn-idx"] = String(this._currentTurnIdx || 1); + + if (this._agentId) headers["x-grok-agent-id"] = this._agentId; + + // Surface model override (CLI always sets this) + if (this._currentModel) headers["x-grok-model-override"] = this._currentModel; + + if (this.config.compactionAt) { + headers["x-compaction-at"] = String(this.config.compactionAt); + } + + // Identity: mapTokens stores email top-level AND in providerSpecificData; + // fall back either way so OAuth connections always fingerprint like the CLI. + const psd = credentials?.providerSpecificData || {}; + const email = psd.email || credentials?.email; + const userId = psd.userId || credentials?.userId || credentials?.providerUserId; + if (email) headers["x-email"] = email; + if (userId) headers["x-userid"] = userId; + + return headers; + } + + parseError(response, bodyText) { + // 402 personal-team-blocked:spending-limit → surface as payment/quota for fallback + if (response.status === 402 && bodyText) { + try { + const json = JSON.parse(bodyText); + const code = json?.code || ""; + const msg = json?.error || json?.message || bodyText; + return { + status: 402, + message: typeof msg === "string" ? msg : bodyText, + code: typeof code === "string" ? code : undefined, + }; + } catch { + /* fall through */ + } + } + return super.parseError(response, bodyText); + } + + transformRequest(model, body, stream, credentials) { + // Session / request ids for headers — stable per client conversation when possible + this._currentSessionId = resolveSessionId({ + headers: credentials?.rawHeaders, + body, + connectionId: credentials?.connectionId || credentials?.id, + workspaceId: credentials?.providerSpecificData?.workspaceId, + scope: "grok-cli", + }); + this._currentReqId = crypto.randomUUID(); + this._agentId = + credentials?.providerSpecificData?.deviceId || + credentials?.providerSpecificData?.agentId || + null; + + // Normalize Responses input + const normalized = normalizeResponsesInput(body.input); + if (normalized) body.input = normalized; + + // Chat Completions clients arrive with messages[] — translator should have + // converted already, but guard empty input. + if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) { + if (Array.isArray(body.messages) && body.messages.length > 0) { + // Soft fallback: map messages → input messages (string content only) + body.input = body.messages.map((m) => ({ + type: "message", + role: m.role || "user", + content: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""), + })); + delete body.messages; + } else { + body.input = [{ type: "message", role: "user", content: "..." }]; + } + } + + // Keep role:"system" as-is — official grok-pager HAR sends system, not developer + // (Codex converts system→developer; Grok CLI does not). + stripStoredItemReferences(body); + normalizeGrokCliTools(body); + + // Turn index after input is finalized (user-message count, monotonic per session) + this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input); + + body.stream = true; + body.store = false; + + // Resolve upstream model id (strip effort suffix virtual models) + let modelEffort = resolveEffortFromModel(body.model || model); + let resolvedModel = body.model || model; + if (modelEffort) { + resolvedModel = resolvedModel.replace(new RegExp(`-${modelEffort}$`), ""); + } + resolvedModel = getModelUpstreamId("gcli", resolvedModel) || resolvedModel; + // Also try provider id key + if (resolvedModel === (body.model || model)) { + resolvedModel = getModelUpstreamId("grok-cli", resolvedModel) || resolvedModel; + } + body.model = resolvedModel; + this._currentModel = resolvedModel; + + // Reasoning effort priority: explicit > reasoning_effort > model suffix > default high + if (!body.reasoning || typeof body.reasoning !== "object") { + const effort = body.reasoning_effort || modelEffort || "high"; + body.reasoning = { effort, summary: "concise" }; + } else { + if (!body.reasoning.effort) { + body.reasoning.effort = body.reasoning_effort || modelEffort || "high"; + } + if (!body.reasoning.summary) body.reasoning.summary = "concise"; + } + delete body.reasoning_effort; + + // Encrypted reasoning for multi-turn continuity (CLI always requests this) + if (body.reasoning?.effort && body.reasoning.effort !== "none") { + const include = Array.isArray(body.include) ? body.include : []; + if (!include.includes("reasoning.encrypted_content")) { + include.push("reasoning.encrypted_content"); + } + body.include = include; + } + + // Drop Chat Completions leftovers that Responses rejects + delete body.messages; + delete body.max_tokens; + delete body.max_completion_tokens; + delete body.n; + delete body.seed; + delete body.logprobs; + delete body.top_logprobs; + delete body.frequency_penalty; + delete body.presence_penalty; + delete body.logit_bias; + delete body.user; + delete body.stream_options; + delete body.prompt_cache_retention; + delete body.safety_identifier; + delete body.previous_response_id; // store=false → cannot resolve + + for (const k of Object.keys(body)) { + if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k]; + } + + return body; + } + + async execute(args) { + // Lazy-resolve stable agent id once per process if connection has none + if (!this._agentId && !args.credentials?.providerSpecificData?.deviceId) { + try { + const mid = await getConsistentMachineId("grok-cli-agent"); + // Format as UUID-ish for header aesthetics + this._agentId = [ + mid.slice(0, 8), + mid.slice(8, 12), + "5" + mid.slice(13, 16), + "a" + mid.slice(17, 20), + mid.slice(0, 12).padEnd(12, "0"), + ].join("-"); + } catch { + this._agentId = crypto.randomUUID(); + } + } else if (args.credentials?.providerSpecificData?.deviceId) { + this._agentId = args.credentials.providerSpecificData.deviceId; + } + + return super.execute(args); + } +} + +export default GrokCliExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 52ae29bb..b6091b9d 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -13,6 +13,7 @@ import { QwenExecutor } from "./qwen.js"; import { OpenCodeExecutor } from "./opencode.js"; import { OpenCodeGoExecutor } from "./opencode-go.js"; import { GrokWebExecutor } from "./grok-web.js"; +import { GrokCliExecutor } from "./grok-cli.js"; import { PerplexityWebExecutor } from "./perplexity-web.js"; import { OllamaLocalExecutor } from "./ollama-local.js"; import { CommandCodeExecutor } from "./commandcode.js"; @@ -39,6 +40,9 @@ const executors = { opencode: new OpenCodeExecutor(), "opencode-go": new OpenCodeGoExecutor(), "grok-web": new GrokWebExecutor(), + "grok-cli": new GrokCliExecutor(), + gcli: new GrokCliExecutor(), // Alias + gb: new GrokCliExecutor(), // Alias (Grok Build) "perplexity-web": new PerplexityWebExecutor(), "ollama-local": new OllamaLocalExecutor(), commandcode: new CommandCodeExecutor(), @@ -77,6 +81,7 @@ export { QwenExecutor } from "./qwen.js"; export { OpenCodeExecutor } from "./opencode.js"; export { OpenCodeGoExecutor } from "./opencode-go.js"; export { GrokWebExecutor } from "./grok-web.js"; +export { GrokCliExecutor } from "./grok-cli.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; export { OllamaLocalExecutor } from "./ollama-local.js"; export { CommandCodeExecutor } from "./commandcode.js"; diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index c73a4bc8..3d302628 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -186,6 +186,8 @@ export const PATTERN_CAPABILITIES = [ // ── Grok (vision + Live Search) ────────────────────────────────── { pattern: "*grok*image*", caps: { imageOutput: true } }, { pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } }, + // Grok 4.5 (Grok CLI / Grok Build): 500k context per cli-chat-proxy /v1/models + { pattern: "*grok-4.5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 64000 } }, { pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } }, { pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 131072 } }, { pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } }, diff --git a/open-sse/providers/registry/grok-cli.js b/open-sse/providers/registry/grok-cli.js new file mode 100644 index 00000000..bc769e1d --- /dev/null +++ b/open-sse/providers/registry/grok-cli.js @@ -0,0 +1,86 @@ +/** + * Grok CLI / Grok Build (cli-chat-proxy.grok.com) + * + * Source of truth: HAR capture of official grok-shell/grok-pager 0.2.93 + * talking to https://cli-chat-proxy.grok.com (OpenAI Responses API). + * + * Distinct from: + * - `xai` → api.x.ai (API key / Grok Build OAuth PKCE) + * - `grok-web` → grok.com web SSO cookie + */ +export default { + id: "grok-cli", + priority: 275, + alias: "gcli", + aliases: ["grok-build", "gb"], + uiAlias: "gcli", + display: { + name: "Grok CLI (Grok Build)", + icon: "auto_awesome", + color: "#1DA1F2", + textIcon: "GC", + website: "https://x.ai", + notice: { + text: "Sign in with your xAI / Grok account via device code. Uses Grok Build subscription credits (cli-chat-proxy.grok.com).", + signupUrl: "https://grok.com/supergrok", + }, + }, + category: "oauth", + authModes: ["oauth"], + hasOAuth: true, + thinkingConfig: { + options: ["low", "medium", "high"], + defaultMode: "high", + }, + transport: { + baseUrl: "https://cli-chat-proxy.grok.com/v1/responses", + format: "openai-responses", + forceStream: true, + modelsUrl: "https://cli-chat-proxy.grok.com/v1/models", + userUrl: "https://cli-chat-proxy.grok.com/v1/user", + billingUrl: "https://cli-chat-proxy.grok.com/v1/billing", + clientVersion: "0.2.93", + clientIdentifier: "grok-pager", + tokenAuth: "xai-grok-cli", + headers: { + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-identifier": "grok-pager", + "x-grok-client-version": "0.2.93", + "x-authenticateresponse": "authenticate-response", + }, + // Compaction threshold mirrored from CLI (x-compaction-at) + compactionAt: 400000, + // Quota tracker: official CLI polls billing?format=credits + user?include=subscription + usage: { + url: "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + userUrl: "https://cli-chat-proxy.grok.com/v1/user?include=subscription", + }, + retry: { + 429: { attempts: 2, delayMs: 2000 }, + 502: { attempts: 2, delayMs: 1500 }, + 503: { attempts: 2, delayMs: 1500 }, + }, + }, + models: [ + { id: "grok-4.5", name: "Grok 4.5" }, + { id: "grok-4.5-high", name: "Grok 4.5 (High)", upstreamModelId: "grok-4.5" }, + { id: "grok-4.5-medium", name: "Grok 4.5 (Medium)", upstreamModelId: "grok-4.5" }, + { id: "grok-4.5-low", name: "Grok 4.5 (Low)", upstreamModelId: "grok-4.5" }, + ], + features: { + usage: true, + }, + oauth: { + // Same public client_id as Grok CLI / existing xai OAuth + clientId: "b1a00492-073a-47ea-816f-4c329264a828", + deviceCodeUrl: "https://auth.x.ai/oauth2/device/code", + tokenUrl: "https://auth.x.ai/oauth2/token", + refreshUrl: "https://auth.x.ai/oauth2/token", + // HAR scope includes conversations read/write beyond the api-only xai scope + scope: + "openid profile email offline_access grok-cli:access api:access conversations:read conversations:write", + referrer: "grok-build", + refreshLeadMs: 5 * 60 * 1000, + }, +}; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 4e25f6de..686c8a46 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -1,4 +1,4 @@ -// Auto-generated: static imports of all registry entries +// Auto-generated: static imports for all registry entries import p0 from "./alicode-intl.js"; import p1 from "./alicode.js"; import p2 from "./anthropic.js"; @@ -41,62 +41,63 @@ import p38 from "./glm-cn.js"; import p39 from "./glm.js"; import p40 from "./google-pse.js"; import p41 from "./google-tts.js"; -import p42 from "./grok-web.js"; -import p43 from "./groq.js"; -import p44 from "./huggingface.js"; -import p45 from "./hyperbolic.js"; -import p46 from "./iflow.js"; -import p47 from "./inworld.js"; -import p48 from "./jina-ai.js"; -import p49 from "./jina-reader.js"; -import p50 from "./kilocode.js"; -import p51 from "./kimchi.js"; -import p52 from "./kimi-coding.js"; -import p53 from "./kimi.js"; -import p54 from "./kiro.js"; -import p55 from "./linkup.js"; -import p56 from "./local-device.js"; -import p57 from "./mimo-free.js"; -import p58 from "./minimax-cn.js"; -import p59 from "./minimax.js"; -import p60 from "./mistral.js"; -import p61 from "./mmf.js"; -import p62 from "./nanobanana.js"; -import p63 from "./nebius.js"; -import p64 from "./nvidia.js"; -import p65 from "./ollama-local.js"; -import p66 from "./ollama.js"; -import p67 from "./openai.js"; -import p68 from "./opencode-go.js"; -import p69 from "./opencode.js"; -import p70 from "./openrouter.js"; -import p71 from "./perplexity-web.js"; -import p72 from "./perplexity.js"; -import p73 from "./playht.js"; -import p74 from "./qoder.js"; -import p75 from "./qwen.js"; -import p76 from "./recraft.js"; -import p77 from "./runwayml.js"; -import p78 from "./sdwebui.js"; -import p79 from "./searchapi.js"; -import p80 from "./searxng.js"; -import p81 from "./serper.js"; -import p82 from "./siliconflow.js"; -import p83 from "./stability-ai.js"; -import p84 from "./tavily.js"; -import p85 from "./together.js"; -import p86 from "./topaz.js"; -import p87 from "./tortoise.js"; -import p88 from "./venice.js"; -import p89 from "./vercel-ai-gateway.js"; -import p90 from "./vertex-partner.js"; -import p91 from "./vertex.js"; -import p92 from "./volcengine-ark.js"; -import p93 from "./voyage-ai.js"; -import p94 from "./xai.js"; -import p95 from "./xiaomi-mimo.js"; -import p96 from "./xiaomi-tokenplan.js"; -import p97 from "./youcom.js"; +import p42 from "./grok-cli.js"; +import p43 from "./grok-web.js"; +import p44 from "./groq.js"; +import p45 from "./huggingface.js"; +import p46 from "./hyperbolic.js"; +import p47 from "./iflow.js"; +import p48 from "./inworld.js"; +import p49 from "./jina-ai.js"; +import p50 from "./jina-reader.js"; +import p51 from "./kilocode.js"; +import p52 from "./kimchi.js"; +import p53 from "./kimi-coding.js"; +import p54 from "./kimi.js"; +import p55 from "./kiro.js"; +import p56 from "./linkup.js"; +import p57 from "./local-device.js"; +import p58 from "./mimo-free.js"; +import p59 from "./minimax-cn.js"; +import p60 from "./minimax.js"; +import p61 from "./mistral.js"; +import p62 from "./mmf.js"; +import p63 from "./nanobanana.js"; +import p64 from "./nebius.js"; +import p65 from "./nvidia.js"; +import p66 from "./ollama-local.js"; +import p67 from "./ollama.js"; +import p68 from "./openai.js"; +import p69 from "./opencode-go.js"; +import p70 from "./opencode.js"; +import p71 from "./openrouter.js"; +import p72 from "./perplexity-web.js"; +import p73 from "./perplexity.js"; +import p74 from "./playht.js"; +import p75 from "./qoder.js"; +import p76 from "./qwen.js"; +import p77 from "./recraft.js"; +import p78 from "./runwayml.js"; +import p79 from "./sdwebui.js"; +import p80 from "./searchapi.js"; +import p81 from "./searxng.js"; +import p82 from "./serper.js"; +import p83 from "./siliconflow.js"; +import p84 from "./stability-ai.js"; +import p85 from "./tavily.js"; +import p86 from "./together.js"; +import p87 from "./topaz.js"; +import p88 from "./tortoise.js"; +import p89 from "./venice.js"; +import p90 from "./vercel-ai-gateway.js"; +import p91 from "./vertex-partner.js"; +import p92 from "./vertex.js"; +import p93 from "./volcengine-ark.js"; +import p94 from "./voyage-ai.js"; +import p95 from "./xai.js"; +import p96 from "./xiaomi-mimo.js"; +import p97 from "./xiaomi-tokenplan.js"; +import p98 from "./youcom.js"; export default [ p0, @@ -196,5 +197,6 @@ export default [ p94, p95, p96, - p97 + p97, + p98, ]; diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index f759493e..c8d264c6 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -129,6 +129,9 @@ const REFRESH_HANDLERS = { github: (c, log) => refreshGitHubToken(c.refreshToken, log), kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log), xai: (c, log) => refreshXaiToken(c.refreshToken, log), + // Grok CLI shares xAI OAuth client + token endpoint (device-code tokens refresh the same way) + "grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log), + gcli: (c, log) => refreshXaiToken(c.refreshToken, log), "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), vertex: vertexRefreshHandler, "vertex-partner": vertexRefreshHandler @@ -187,6 +190,7 @@ export function formatProviderCredentials(provider, credentials, log) { case "openai": case "openrouter": case "xai": + case "grok-cli": return { apiKey: credentials.apiKey, accessToken: credentials.accessToken diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 4c56dc1b..43bfc8ed 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -11,6 +11,7 @@ export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits }; import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; +import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getQwenUsage, getIflowUsage, @@ -43,6 +44,7 @@ const USAGE_HANDLERS = { "minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions), "vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions), "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), + "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js new file mode 100644 index 00000000..b412b5ee --- /dev/null +++ b/open-sse/services/usage/grok-cli.js @@ -0,0 +1,274 @@ +/** + * Grok CLI / Grok Build usage handler + * + * Source of truth: official grok-shell/grok-pager traffic to cli-chat-proxy.grok.com + * GET /v1/billing?format=credits + * GET /v1/user?include=subscription + * + * Observed billing shape (protobuf-json style `{ val: number }`): + * { + * config: { + * currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start, end }, + * onDemandCap: { val }, + * onDemandUsed: { val }, + * prepaidBalance: { val }, + * isUnifiedBillingUser: true, + * billingPeriodStart, billingPeriodEnd + * } + * } + * + * Exhausted free/promo accounts return cap=0/used=0/prepaid=0 and chat 402s with + * personal-team-blocked:spending-limit. Paid/sub accounts surface non-zero cap + * or prepaidBalance; richer credit fields are parsed opportunistically if present. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U, parseResetTime, toFiniteNumber } from "./shared.js"; + +const USAGE = U("grok-cli"); +const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; + +/** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */ +function unwrapVal(value, fallback = 0) { + if (value == null) return fallback; + if (typeof value === "object" && !Array.isArray(value) && "val" in value) { + return toFiniteNumber(value.val, fallback); + } + return toFiniteNumber(value, fallback); +} + +function buildGrokCliHeaders(accessToken, providerSpecificData = {}) { + const psd = providerSpecificData || {}; + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-identifier": "grok-pager", + "x-grok-client-version": "0.2.93", + }; + const email = psd.email; + const userId = psd.userId || psd.principalId; + if (email) headers["x-email"] = email; + if (userId) headers["x-userid"] = userId; + return headers; +} + +function resolvePlan(user, config) { + const tier = typeof user?.subscriptionTier === "string" ? user.subscriptionTier.trim() : ""; + if (tier) { + return tier + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + } + if (user?.hasGrokCodeAccess === true) return "Grok Code"; + if (config?.isUnifiedBillingUser === true) return "Grok Build"; + return "Grok Build"; +} + +function makeQuota({ used, total, resetAt, unlimited = false }) { + const safeTotal = Math.max(0, toFiniteNumber(total, 0)); + const safeUsed = Math.max(0, toFiniteNumber(used, 0)); + // Do NOT set absolute `remaining` — QuotaTable's getRemainingPercentage treats + // `remaining` as a 0–100 percentage (same trap as Qoder credits). + if (unlimited || safeTotal === 0) { + return { + used: safeUsed, + total: 0, + remainingPercentage: unlimited ? 100 : 0, + resetAt: resetAt || null, + unlimited: true, + }; + } + const remaining = Math.max(0, safeTotal - safeUsed); + const remainingPercentage = (remaining / safeTotal) * 100; + return { + used: safeUsed, + total: safeTotal, + remainingPercentage, + resetAt: resetAt || null, + unlimited: false, + }; +} + +/** + * Map billing JSON → normalized quotas object for the dashboard. + * Returns { quotas, periodEnd, exhaustedHint } or empty quotas when nothing usable. + */ +export function parseGrokCliBilling(billing, user = null) { + const root = billing && typeof billing === "object" ? billing : {}; + const config = + root.config && typeof root.config === "object" && !Array.isArray(root.config) + ? root.config + : root; + + const periodEnd = + parseResetTime(config.billingPeriodEnd) || + parseResetTime(config.currentPeriod?.end) || + parseResetTime(root.billingPeriodEnd) || + null; + + const quotas = {}; + + // Primary: on-demand spending window (subscription / promo credits) + const onDemandCap = unwrapVal(config.onDemandCap ?? root.onDemandCap, NaN); + const onDemandUsed = unwrapVal(config.onDemandUsed ?? root.onDemandUsed, NaN); + if (Number.isFinite(onDemandCap) && onDemandCap > 0) { + const used = Number.isFinite(onDemandUsed) ? Math.max(0, onDemandUsed) : 0; + quotas["On-demand"] = makeQuota({ + used, + total: onDemandCap, + resetAt: periodEnd, + }); + } else if (Number.isFinite(onDemandCap) && onDemandCap === 0 && Number.isFinite(onDemandUsed)) { + // Cap 0 is the exhausted free/promo state (chat returns 402 spending-limit). + // UI treats total===0 as unlimited, so use a synthetic 1/1 depleted row. + quotas["On-demand"] = { + used: 1, + total: 1, + remainingPercentage: 0, + resetAt: periodEnd, + unlimited: false, + }; + } + + // Prepaid top-up balance (remaining credits; no fixed allotment known) + const prepaid = unwrapVal(config.prepaidBalance ?? root.prepaidBalance, NaN); + if (Number.isFinite(prepaid) && prepaid > 0) { + // Show full bar against the current balance (0 spent of this remaining pot). + quotas["Prepaid"] = { + used: 0, + total: prepaid, + remainingPercentage: 100, + resetAt: null, + unlimited: false, + }; + } + + // Opportunistic richer credit envelopes (future / other account types) + const creditBags = [ + root.credits, + root.creditBalance, + root.usage, + config.credits, + config.includedCredits, + config.subscriptionCredits, + ].filter((bag) => bag && typeof bag === "object" && !Array.isArray(bag)); + + for (const bag of creditBags) { + const total = unwrapVal( + bag.total ?? bag.limit ?? bag.cap ?? bag.allocation ?? bag.amount, + NaN, + ); + const used = unwrapVal(bag.used ?? bag.spent ?? bag.consumed, NaN); + const remaining = unwrapVal(bag.remaining ?? bag.balance ?? bag.left, NaN); + if (Number.isFinite(total) && total > 0) { + const resolvedUsed = Number.isFinite(used) + ? used + : Number.isFinite(remaining) + ? Math.max(0, total - remaining) + : 0; + if (!quotas.Credits) { + quotas.Credits = makeQuota({ + used: resolvedUsed, + total, + resetAt: parseResetTime(bag.resetAt || bag.resetsAt || bag.end) || periodEnd, + }); + } + } else if (Number.isFinite(remaining) && remaining >= 0 && !quotas.Credits) { + quotas.Credits = { + used: 0, + total: remaining > 0 ? remaining : 1, + remainingPercentage: remaining > 0 ? 100 : 0, + resetAt: periodEnd, + unlimited: false, + }; + } + } + + // Exhausted when every finite quota bar is at 0% remaining + const exhausted = + Object.keys(quotas).length > 0 && + Object.values(quotas).every( + (q) => q.unlimited !== true && (q.remainingPercentage ?? 100) <= 0, + ); + + return { + plan: resolvePlan(user, config), + quotas, + periodEnd, + exhausted, + rawConfig: config, + }; +} + +/** + * @param {string} accessToken + * @param {object|null} providerSpecificData + * @param {object|null} proxyOptions + */ +export async function getGrokCliUsage(accessToken, providerSpecificData = null, proxyOptions = null) { + if (!accessToken) { + return { message: "Grok CLI access token not available." }; + } + + const headers = buildGrokCliHeaders(accessToken, providerSpecificData); + + try { + // Fetch billing + user profile in parallel (same pattern as official CLI startup) + const [billingRes, userRes] = await Promise.all([ + proxyAwareFetch( + BILLING_URL, + { method: "GET", headers }, + proxyOptions, + ), + proxyAwareFetch( + USER_URL, + { method: "GET", headers }, + proxyOptions, + ).catch(() => null), + ]); + + if (billingRes.status === 401 || billingRes.status === 403) { + return { message: "Grok CLI authentication expired. Please re-authorize." }; + } + + if (!billingRes.ok) { + const errText = await billingRes.text().catch(() => ""); + const trimmed = errText ? `: ${errText.slice(0, 200)}` : ""; + return { message: `Grok CLI billing API error (${billingRes.status})${trimmed}` }; + } + + const billing = await billingRes.json().catch(() => null); + if (!billing || typeof billing !== "object") { + return { message: "Grok CLI billing response was not JSON." }; + } + + let user = null; + if (userRes?.ok) { + user = await userRes.json().catch(() => null); + } + + const parsed = parseGrokCliBilling(billing, user); + + if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) { + return { + plan: parsed.plan, + message: + "Grok Build connected, but no credit allotment was returned. Free promo may be exhausted — upgrade at https://grok.com/supergrok or add credits at https://grok.com/?_s=usage.", + quotas: {}, + }; + } + + // Dashboard hides QuotaTable whenever `message` is set, so only attach a + // message when there are no quota rows to render. Depleted accounts keep + // the 0% On-demand bar without a blocking message. + return { + plan: parsed.plan, + quotas: parsed.quotas, + }; + } catch (error) { + return { message: `Grok CLI usage error: ${error.message}` }; + } +} diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 23603a71..abd79a47 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -31,11 +31,12 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) let currentAssistantMsg = null; let pendingToolResults = []; let pendingReasoning = ""; + let pendingReasoningEncrypted = ""; const inputItems = normalizeResponsesInput(body.input); if (!inputItems) return body; - // Extract reasoning text from summary[].text or encrypted_content fallback + // Extract reasoning text from summary[].text (encrypted_content is continuity-only) const extractReasoningText = (item) => { if (Array.isArray(item.summary)) { const txt = item.summary.map(s => s?.text || "").filter(Boolean).join("\n"); @@ -48,6 +49,13 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) return ""; }; + const attachPendingReasoning = (msg) => { + if (pendingReasoning) msg.reasoning_content = pendingReasoning; + if (pendingReasoningEncrypted) msg.encrypted_content = pendingReasoningEncrypted; + pendingReasoning = ""; + pendingReasoningEncrypted = ""; + }; + for (const item of inputItems) { // Determine item type - Droid CLI sends role-based items without 'type' field // Fallback: if no type but has role property, treat as message @@ -80,11 +88,12 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) }) : item.content; const msg = { role: item.role, content }; - // Attach buffered reasoning to assistant turn (required by xiaomi-mimo thinking mode) - if (item.role === ROLE.ASSISTANT && pendingReasoning) { - msg.reasoning_content = pendingReasoning; + // Attach buffered reasoning to assistant turn (required by xiaomi-mimo + store=false continuity) + if (item.role === ROLE.ASSISTANT) attachPendingReasoning(msg); + else { + pendingReasoning = ""; + pendingReasoningEncrypted = ""; } - pendingReasoning = ""; result.messages.push(msg); } else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) { @@ -95,10 +104,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) content: null, tool_calls: [] }; - if (pendingReasoning) { - currentAssistantMsg.reasoning_content = pendingReasoning; - pendingReasoning = ""; - } + attachPendingReasoning(currentAssistantMsg); } // Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444) if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue; @@ -132,9 +138,15 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) }); } else if (itemType === RESPONSES_ITEM.REASONING) { - // Buffer reasoning text; attached to next assistant message/function_call + // Buffer reasoning text; attached to next assistant message/function_call. + // Also stash encrypted_content so a later openai→responses hop can restore + // the store=false continuity blob (Grok CLI / Codex multi-turn). const txt = extractReasoningText(item); if (txt) pendingReasoning = pendingReasoning ? `${pendingReasoning}\n${txt}` : txt; + if (typeof item.encrypted_content === "string" && item.encrypted_content) { + // Prefer attaching to the next assistant message we create + pendingReasoningEncrypted = item.encrypted_content; + } continue; } } @@ -202,6 +214,43 @@ function normalizeToolParameters(params) { return params; } +/** + * Build a Responses `reasoning` input item from Chat Completions assistant fields. + * Preserves encrypted blobs needed by store=false multi-turn (Grok CLI / Codex). + * Returns null when the message has nothing useful to re-send. + */ +function buildReasoningInputItem(msg) { + if (!msg || typeof msg !== "object") return null; + + const encrypted = + (typeof msg.encrypted_content === "string" && msg.encrypted_content) || + (typeof msg.reasoning_encrypted_content === "string" && msg.reasoning_encrypted_content) || + (typeof msg.reasoning?.encrypted_content === "string" && msg.reasoning.encrypted_content) || + ""; + + let summaryText = ""; + if (typeof msg.reasoning_content === "string" && msg.reasoning_content.trim()) { + summaryText = msg.reasoning_content; + } else if (typeof msg.reasoning === "string" && msg.reasoning.trim()) { + summaryText = msg.reasoning; + } else if (Array.isArray(msg.reasoning_details)) { + summaryText = msg.reasoning_details + .map((d) => (typeof d?.text === "string" ? d.text : typeof d?.content === "string" ? d.content : "")) + .filter(Boolean) + .join("\n"); + } + + if (!encrypted && !summaryText) return null; + + const item = { type: RESPONSES_ITEM.REASONING }; + if (summaryText) { + item.summary = [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: summaryText }]; + } + // encrypted_content is the continuity token for store=false backends + if (encrypted) item.encrypted_content = encrypted; + return item; +} + /** * Convert OpenAI Chat Completions to OpenAI Responses API format */ @@ -233,6 +282,14 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) // Convert user/assistant messages to input items if (msg.role === ROLE.USER || msg.role === ROLE.ASSISTANT) { + // Multi-turn continuity for store=false Responses backends (Codex / Grok CLI): + // re-emit a reasoning item before the assistant message when the chat-format + // history carried reasoning text and/or encrypted_content from a prior turn. + if (msg.role === ROLE.ASSISTANT) { + const reasoningItem = buildReasoningInputItem(msg); + if (reasoningItem) result.input.push(reasoningItem); + } + const contentType = msg.role === ROLE.USER ? RESPONSES_ITEM.INPUT_TEXT : RESPONSES_ITEM.OUTPUT_TEXT; const content = typeof msg.content === "string" ? [{ type: contentType, text: msg.content }] diff --git a/package.json b/package.json index 5dd951e8..b19693b8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "next dev --webpack --port 20127", "build": "next build --webpack", - "start": "next start", + "start": "next start --port 20127", "dev:bun": "bun --bun next dev --webpack --port 20127", "build:bun": "bun --bun next build --webpack", "start:bun": "bun ./.next/standalone/server.js", diff --git a/public/providers/grok-cli.png b/public/providers/grok-cli.png new file mode 100644 index 00000000..ef9d7abc Binary files /dev/null and b/public/providers/grok-cli.png differ diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 07158ee4..845eed85 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -148,7 +148,10 @@ export default function ProviderDetailPage() { const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); const isCompatible = isOpenAICompatible || isAnthropicCompatible; const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth; - const oauthConnectionLabel = providerId === "xai" ? "Grok Build OAuth" : "OAuth"; + const oauthConnectionLabel = + providerId === "xai" ? "Grok Build OAuth" + : providerId === "grok-cli" ? "Grok CLI Device Login" + : "OAuth"; const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key"; // Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it. const resolveThinkingSuffix = (modelId) => { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 688f0ab7..06cc3232 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -451,6 +451,23 @@ export function parseQuotaData(provider, data) { } break; + case "grok-cli": + // Grok Build credits (on-demand window + prepaid balance). + // Do NOT forward absolute `remaining` — getRemainingPercentage treats + // it as a 0–100 percentage (same as Qoder). Use remainingPercentage. + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index ce7fdbc5..57bff272 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -150,8 +150,16 @@ export async function GET(request, { params }) { } : undefined; - // Providers that don't use PKCE for device code - const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"]; + // Providers that don't use PKCE for device code (Grok CLI HAR: plain device_code, no challenge) + const noPkceDeviceProviders = [ + "github", + "kiro", + "kimi-coding", + "kilocode", + "codebuddy-cn", + "qoder", + "grok-cli", + ]; let deviceData; if (noPkceDeviceProviders.includes(provider)) { deviceData = await requestDeviceCode(provider, undefined, deviceOptions); diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index a58281d1..a05e6f4e 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -103,8 +103,61 @@ const OAUTH_TEST_CONFIG = { }, refreshable: false, }, + // Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI. + "grok-cli": { + url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { + Accept: "application/json", + ...(PROVIDERS["grok-cli"]?.headers || { + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-identifier": "grok-pager", + "x-grok-client-version": "0.2.93", + }), + }, + refreshable: true, + // Subscription spending-limit is not an auth failure — token is fine, credits aren't. + // Accept 402 so the connection stays "active" with a warning (same idea as Codex 400). + acceptStatuses: [402], + softFailMessage: { + 402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.", + }, + }, }; +/** + * Classify an OAuth probe response as success / soft-success / hard-fail. + * Soft success (e.g. 402 spending-limit on Grok CLI) means auth works but the + * account cannot spend — keep connection active and surface a warning. + * Exported for unit tests. + */ +export function classifyOAuthProbeResult(res, config, bodyText = "") { + if (!res) return { valid: false, error: "No response", soft: false }; + const status = res.status; + const accepted = res.ok || (config?.acceptStatuses && config.acceptStatuses.includes(status)); + if (!accepted) { + if (status === 401) return { valid: false, error: "Token invalid or revoked", soft: false }; + if (status === 403) return { valid: false, error: "Access denied", soft: false }; + return { valid: false, error: `API returned ${status}`, soft: false }; + } + + // Soft success only when the provider configured an explicit message for this + // status (e.g. Grok CLI 402 spending-limit). Codex-style acceptStatuses:[400] + // stays silent success — 400 there only proves auth, not a user-facing warning. + if (!res.ok && config?.acceptStatuses?.includes(status)) { + const softMap = config.softFailMessage || {}; + if (softMap[status]) { + return { valid: true, error: softMap[status], soft: true }; + } + return { valid: true, error: null, soft: false }; + } + + return { valid: true, error: null, soft: false }; +} + async function probeClineAccessToken(accessToken) { const res = await fetch("https://api.cline.bot/api/v1/users/me", { method: "GET", @@ -186,7 +239,7 @@ async function refreshOAuthToken(connection) { return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; } - if (provider === "codex") { + if (provider === "codex" || provider === "grok-cli" || provider === "xai") { return await refreshProviderCredentials(provider, connection, console); } @@ -362,9 +415,19 @@ async function testOAuthConnection(connection, effectiveProxy = null) { const fetchOpts = { method: config.method, headers }; if (config.body) fetchOpts.body = config.body; const res = await fetchWithConnectionProxy(testUrl, fetchOpts, effectiveProxy); + const bodyText = !res.ok ? await res.text().catch(() => "") : ""; - const accepted = res.ok || (config.acceptStatuses && config.acceptStatuses.includes(res.status)); - if (accepted) return { valid: true, error: null, refreshed, newTokens }; + const classified = classifyOAuthProbeResult(res, config, bodyText); + if (classified.valid) { + return { + valid: true, + // soft success surfaces warning text without marking connection error + error: classified.soft ? classified.error : null, + warning: classified.soft ? classified.error : null, + refreshed, + newTokens, + }; + } if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) { const tokens = await refreshOAuthToken(connection); @@ -376,15 +439,22 @@ async function testOAuthConnection(connection, effectiveProxy = null) { const retryOpts = { method: config.method, headers: retryHeaders }; if (config.body) retryOpts.body = config.body; const retryRes = await fetchWithConnectionProxy(retryUrl, retryOpts, effectiveProxy); - const retryAccepted = retryRes.ok || (config.acceptStatuses && config.acceptStatuses.includes(retryRes.status)); - if (retryAccepted) return { valid: true, error: null, refreshed: true, newTokens: tokens }; + const retryBody = !retryRes.ok ? await retryRes.text().catch(() => "") : ""; + const retryClassified = classifyOAuthProbeResult(retryRes, config, retryBody); + if (retryClassified.valid) { + return { + valid: true, + error: retryClassified.soft ? retryClassified.error : null, + warning: retryClassified.soft ? retryClassified.error : null, + refreshed: true, + newTokens: tokens, + }; + } } return { valid: false, error: "Token invalid or revoked", refreshed: false }; } - if (res.status === 401) return { valid: false, error: "Token invalid or revoked", refreshed }; - if (res.status === 403) return { valid: false, error: "Access denied", refreshed }; - return { valid: false, error: `API returned ${res.status}`, refreshed }; + return { valid: false, error: classified.error, refreshed }; } catch (err) { return { valid: false, error: err.message, refreshed }; } @@ -752,10 +822,18 @@ export async function testSingleConnection(id) { const latencyMs = Date.now() - start; + // Soft success (e.g. Grok CLI 402 spending-limit): credentials are good, account is + // out of credits. Keep testStatus active; surface the message as lastError so the + // dashboard can show a warning without marking the connection broken. + const softWarning = result.valid && (result.warning || result.error); const updateData = { testStatus: result.valid ? "active" : "error", - lastError: result.valid ? null : result.error, - lastErrorAt: result.valid ? null : new Date().toISOString(), + lastError: result.valid ? (softWarning || null) : result.error, + lastErrorAt: result.valid + ? softWarning + ? new Date().toISOString() + : null + : new Date().toISOString(), }; if (result.refreshed && result.newTokens) { diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index 2f4715cf..1188b1fb 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -114,6 +114,10 @@ export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] }; // Kimchi OAuth Configuration (Browser token callback flow) export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; +// Grok CLI / Grok Build OAuth Configuration (Device Code Flow) +// Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes +export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] }; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -137,4 +141,5 @@ export const PROVIDERS = { GITLAB: "gitlab", CODEBUDDY: "codebuddy-cn", KIMCHI: "kimchi", + GROK_CLI: "grok-cli", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index e950bbe8..484ecf99 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -27,6 +27,7 @@ import { GITLAB_CONFIG, CODEBUDDY_CONFIG, KIMCHI_CONFIG, + GROK_CLI_CONFIG, getOAuthClientMetadata, } from "./constants/oauth"; import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai"; @@ -255,6 +256,122 @@ const PROVIDERS = { }, }, + // Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com + "grok-cli": { + config: GROK_CLI_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const body = new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + }); + // Official CLI sends referrer=grok-build + if (config.referrer) body.set("referrer", config.referrer); + + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + }, + body, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Grok CLI device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: config.clientId, + }), + }); + + let data; + try { + data = await response.json(); + } catch { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + // Device flow: 400 + authorization_pending is expected while user authorizes + const pending = + data?.error === "authorization_pending" || + data?.error === "slow_down"; + return { + ok: response.ok || pending, + data, + }; + }, + postExchange: async (tokens) => { + // Best-effort user profile from cli-chat-proxy (non-fatal) + try { + const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": "0.2.93", + }, + }); + if (res.ok) return { user: await res.json() }; + } catch { + /* ignore */ + } + return { user: null }; + }, + mapTokens: (tokens, extra) => { + const email = + decodeXaiIdTokenEmail(tokens.id_token) || + extractEmailFromAccessToken(tokens.access_token) || + extra?.user?.email || + null; + const userId = + extra?.user?.userId || + extra?.user?.principalId || + null; + const displayName = [extra?.user?.firstName, extra?.user?.lastName] + .filter(Boolean) + .join(" ") + .trim() || null; + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresIn: tokens.expires_in, + scope: tokens.scope, + // Top-level for dashboard connection cards + email: email || undefined, + displayName: displayName || undefined, + // Mirror identity into providerSpecificData so GrokCliExecutor can set + // x-email / x-userid without depending on top-level credential shape. + providerSpecificData: { + authMethod: "device_code", + idToken: tokens.id_token || null, + email: email || null, + userId, + hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null, + subscriptionTier: extra?.user?.subscriptionTier ?? null, + }, + }; + }, + }, + "gemini-cli": { config: GEMINI_CONFIG, flowType: "authorization_code", diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 6cb6f4f8..6d7b2bba 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -156,8 +156,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, try { setError(null); - // Device code flow providers - const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"]; + // Device code flow providers (must match oauth providers with flowType: "device_code") + const deviceCodeProviders = [ + "github", + "qwen", + "kiro", + "kimi-coding", + "kilocode", + "codebuddy-cn", + "qoder", + "grok-cli", + ]; if (deviceCodeProviders.includes(provider)) { setIsDeviceCode(true); setStep("waiting"); @@ -277,6 +286,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide }); + // Guard: device_code providers return authUrl:null from /authorize. Never window.open(null) + // (browsers coerce it to the relative path ".../null"). + if (!data.authUrl) { + if (data.flowType === "device_code") { + throw new Error( + `Provider ${provider} uses device-code login but is not wired in the OAuth modal device-code list` + ); + } + throw new Error("No authorization URL returned from OAuth provider"); + } + if (provider === "codex" && codexProxyActive) { // Proxy active: callback will be handled server-side (auto-exchange) or via channels (fallback) setStep("waiting"); diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index 43864392..c104335e 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -66,6 +66,10 @@ "vertex-partner": "vertex-partner", "gw": "grok-web", "grok-web": "grok-web", + "gcli": "grok-cli", + "gb": "grok-cli", + "grok-build": "grok-cli", + "grok-cli": "grok-cli", "pw": "perplexity-web", "perplexity-web": "perplexity-web", "mimo": "xiaomi-mimo", @@ -104,6 +108,7 @@ "chutes": "chutes", "claude": "cc", "cline": "cl", + "clinepass": "clinepass", "cloudflare-ai": "cloudflare-ai", "codebuddy-cn": "cbcn", "codex": "cx", @@ -119,11 +124,13 @@ "gitlab": "gitlab", "glm": "glm", "glm-cn": "glm-cn", + "grok-cli": "gcli", "grok-web": "grok-web", "groq": "groq", "hyperbolic": "hyperbolic", "iflow": "if", "kilocode": "kc", + "kimchi": "kimchi", "kimi": "kimi", "kimi-coding": "kmc", "kiro": "kr", @@ -147,6 +154,7 @@ "qwen": "qw", "siliconflow": "siliconflow", "together": "together", + "venice": "venice", "vercel-ai-gateway": "vercel-ai-gateway", "vertex": "vertex", "vertex-partner": "vertex-partner", @@ -168,6 +176,7 @@ "cc", "cerebras", "cl", + "clinepass", "cloudflare-ai", "cohere", "comfyui", @@ -181,6 +190,7 @@ "fal-ai", "fireworks", "gc", + "gcli", "gemini", "gemini-tts-models", "gemini-tts-voices", @@ -194,6 +204,7 @@ "hyperbolic", "if", "kc", + "kimchi", "kimi", "kmc", "kr", @@ -224,6 +235,7 @@ "siliconflow", "stability-ai", "together", + "venice", "vertex", "vertex-partner", "volcengine-ark", diff --git a/tests/__baseline__/oauth-urls-baseline.json b/tests/__baseline__/oauth-urls-baseline.json index 44faad70..e2049194 100644 --- a/tests/__baseline__/oauth-urls-baseline.json +++ b/tests/__baseline__/oauth-urls-baseline.json @@ -13,8 +13,8 @@ "auth": "https://api.anthropic.com/v1/oauth/authorize" }, "qwen": { - "token": "https://qwen.ai/api/v1/oauth2/token", - "auth": "https://qwen.ai/api/v1/oauth2/device/code" + "token": "https://chat.qwen.ai/api/v1/oauth2/token", + "auth": "https://chat.qwen.ai/api/v1/oauth2/device/code" }, "iflow": { "token": "https://iflow.cn/oauth/token", @@ -33,24 +33,25 @@ "iflow": "https://iflow.cn/oauth/token", "kiro": "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", "xai": "https://auth.x.ai/oauth2/token", + "grok-cli": "https://auth.x.ai/oauth2/token", "cline": "https://api.cline.bot/api/v1/auth/token", "kimi-coding": "https://auth.kimi.com/api/oauth/token" }, "authUrls": { - "qwen": "https://chat.qwen.ai/api/v1/oauth2/device/code", - "iflow": "https://iflow.cn/oauth", "kiro": "https://prod.us-east-1.auth.desktop.kiro.dev" }, "refreshUrls": { "cline": "https://api.cline.bot/api/v1/auth/refresh", "kimi-coding": "https://auth.kimi.com/api/oauth/token", - "xai": "https://auth.x.ai/oauth2/token" + "xai": "https://auth.x.ai/oauth2/token", + "grok-cli": "https://auth.x.ai/oauth2/token" }, "clientIds": { "claude": "9d1c250a-e61b-44d9-88ed-5944d1962f5e", "codex": "app_EMoamEEZ73f0CkXaXp7hrann", "qwen": "f0304373b74a44d2b584a3fb70ca9e56", "iflow": "10009311001", - "kimi-coding": "17e5f671-d194-4dfb-9706-5516cb48c098" + "kimi-coding": "17e5f671-d194-4dfb-9706-5516cb48c098", + "grok-cli": "b1a00492-073a-47ea-816f-4c329264a828" } } \ No newline at end of file diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 251e221e..8635e9e4 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -1,7 +1,94 @@ { + "alicode-intl": { + "baseUrl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", + "headers": {}, + "quirks": { + "preserveCacheControl": true + }, + "format": "openai" + }, + "alicode": { + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1/chat/completions", + "headers": {}, + "quirks": { + "preserveCacheControl": true + }, + "format": "openai" + }, + "anthropic": { + "baseUrl": "https://api.anthropic.com/v1/messages", + "format": "claude", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + } + }, + "antigravity": { + "baseUrls": [ + "https://daily-cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com" + ], + "format": "antigravity", + "headers": { + "User-Agent": "antigravity/1.107.0 darwin/arm64" + }, + "retry": { + "429": { + "attempts": 3 + }, + "500": { + "attempts": 3 + }, + "503": { + "attempts": 3 + } + }, + "usage": { + "quotaApiUrl": "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", + "loadProjectApiUrl": "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + "tokenUrl": "https://oauth2.googleapis.com/token" + }, + "clientId": "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + "clientSecret": "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", + "tokenUrl": "https://oauth2.googleapis.com/token" + }, + "assemblyai": { + "baseUrl": "https://api.assemblyai.com/v1/audio/transcriptions", + "validateUrl": "https://api.assemblyai.com/v1/account", + "format": "openai" + }, + "azure": { + "baseUrl": "", + "headers": {}, + "format": "openai" + }, + "blackbox": { + "baseUrl": "https://api.blackbox.ai/v1/chat/completions", + "thinkingFormat": "openai", + "format": "openai" + }, + "byteplus": { + "baseUrl": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions", + "headers": {}, + "format": "openai" + }, + "cerebras": { + "baseUrl": "https://api.cerebras.ai/v1/chat/completions", + "validateUrl": "https://api.cerebras.ai/v1/models", + "quirks": { + "dropClientMetadata": true + }, + "format": "openai" + }, + "chutes": { + "baseUrl": "https://llm.chutes.ai/v1/chat/completions", + "validateUrl": "https://llm.chutes.ai/v1/models", + "format": "openai" + }, "claude": { "baseUrl": "https://api.anthropic.com/v1/messages", "format": "claude", + "urlSuffix": "?beta=true", "headers": { "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", @@ -18,148 +105,208 @@ "X-Stainless-Os": "MacOS", "X-Stainless-Timeout": "600" }, + "quirks": { + "cloakToolsOnOAuth": true + }, + "auth": { + "apiKey": { + "header": "x-api-key", + "scheme": "raw" + }, + "oauth": { + "header": "Authorization", + "scheme": "bearer" + }, + "hooks": [ + "claudeOverlay" + ] + }, + "usage": { + "oauthUrl": "https://api.anthropic.com/api/oauth/usage", + "orgUrl": "https://api.anthropic.com/v1/organizations/{org_id}/usage", + "settingsUrl": "https://api.anthropic.com/v1/settings" + }, "clientId": "9d1c250a-e61b-44d9-88ed-5944d1962f5e", "tokenUrl": "https://api.anthropic.com/v1/oauth/token" }, + "cline": { + "baseUrl": "https://api.cline.bot/api/v1/chat/completions", + "headers": { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline" + }, + "tokenUrl": "https://api.cline.bot/api/v1/auth/token", + "refreshUrl": "https://api.cline.bot/api/v1/auth/refresh", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer", + "hooks": [ + "clineHeaders" + ] + }, + "format": "openai" + }, + "clinepass": { + "baseUrl": "https://api.cline.bot/api/v1/chat/completions", + "headers": { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline" + }, + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer", + "hooks": [ + "clineHeaders" + ] + }, + "format": "openai", + "tokenUrl": "https://api.cline.bot/api/v1/auth/token" + }, + "cloudflare-ai": { + "baseUrl": "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions", + "thinkingFormat": "openai", + "format": "openai" + }, + "codebuddy-cn": { + "baseUrl": "https://copilot.tencent.com/v2/chat/completions", + "forceStream": true, + "thinkingFormat": "openai", + "headers": { + "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "CLI", + "X-IDE-Name": "CLI", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1" + }, + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + }, + "usage": { + "url": "https://copilot.tencent.com/v2/billing/meter/get-user-resource" + }, + "format": "openai", + "tokenUrl": "https://copilot.tencent.com/v2/plugin/auth/token" + }, + "codex": { + "baseUrl": "https://chatgpt.com/backend-api/codex/responses", + "format": "openai-responses", + "forceStream": true, + "headers": { + "originator": "codex_cli_rs", + "User-Agent": "codex_cli_rs/0.136.0" + }, + "usage": { + "url": "https://chatgpt.com/backend-api/wham/usage", + "resetCreditsUrl": "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", + "resetCreditsConsumeUrl": "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume" + }, + "clientId": "app_EMoamEEZ73f0CkXaXp7hrann", + "tokenUrl": "https://auth.openai.com/oauth/token" + }, + "cohere": { + "baseUrl": "https://api.cohere.ai/v1/chat/completions", + "validateUrl": "https://api.cohere.ai/v1/models", + "format": "openai" + }, + "commandcode": { + "baseUrl": "https://api.commandcode.ai/alpha/generate", + "format": "commandcode", + "forceStream": true, + "headers": { + "x-command-code-version": "0.25.7", + "x-cli-environment": "cli" + } + }, + "cursor": { + "baseUrl": "https://api2.cursor.sh", + "chatPath": "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", + "format": "cursor", + "headers": { + "connect-accept-encoding": "gzip", + "connect-protocol-version": "1", + "Content-Type": "application/connect+proto", + "User-Agent": "connect-es/1.6.1" + }, + "clientVersion": "3.1.0" + }, + "deepgram": { + "baseUrl": "https://api.deepgram.com/v1/listen", + "format": "openai" + }, + "deepseek": { + "baseUrl": "https://api.deepseek.com/chat/completions", + "validateUrl": "https://api.deepseek.com/models", + "reasoningInject": { + "scope": "all" + }, + "format": "openai", + "transports": [ + { + "format": "openai", + "baseUrl": "https://api.deepseek.com/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "baseUrl": "https://api.deepseek.com/anthropic/v1/messages", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + } + } + ] + }, + "fireworks": { + "baseUrl": "https://api.fireworks.ai/inference/v1/chat/completions", + "validateUrl": "https://api.fireworks.ai/inference/v1/models", + "format": "openai" + }, + "gemini-cli": { + "baseUrl": "https://cloudcode-pa.googleapis.com/v1internal", + "format": "gemini-cli", + "cliVersion": "0.34.0", + "apiClient": "google-genai-sdk/1.41.0 gl-node/v22.19.0", + "usage": { + "quotaUrl": "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + "loadCodeAssistUrl": "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + }, + "clientId": "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + "clientSecret": "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", + "tokenUrl": "https://oauth2.googleapis.com/token" + }, "gemini": { "baseUrl": "https://generativelanguage.googleapis.com/v1beta/models", "format": "gemini", "clientId": "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", - "clientSecret": "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" - }, - "gemini-cli": { - "baseUrl": "https://cloudcode-pa.googleapis.com/v1internal", - "format": "gemini-cli", - "clientId": "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", - "clientSecret": "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" - }, - "codex": { - "baseUrl": "https://chatgpt.com/backend-api/codex/responses", - "format": "openai-responses", - "headers": { - "originator": "codex_cli_rs", - "User-Agent": "codex_cli_rs/0.136.0" - }, - "clientId": "app_EMoamEEZ73f0CkXaXp7hrann", - "tokenUrl": "https://auth.openai.com/oauth/token" - }, - "qwen": { - "baseUrl": "https://portal.qwen.ai/v1/chat/completions", - "format": "openai", - "clientId": "f0304373b74a44d2b584a3fb70ca9e56", - "tokenUrl": "https://chat.qwen.ai/api/v1/oauth2/token", - "authUrl": "https://chat.qwen.ai/api/v1/oauth2/device/code" - }, - "iflow": { - "baseUrl": "https://apis.iflow.cn/v1/chat/completions", - "format": "openai", - "headers": { - "User-Agent": "iFlow-Cli" - }, - "clientId": "10009311001", - "clientSecret": "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", - "tokenUrl": "https://iflow.cn/oauth/token", - "authUrl": "https://iflow.cn/oauth" - }, - "qoder": { - "baseUrl": "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", - "format": "openai", - "headers": {}, - "timeoutMs": 120000, - "stallTimeoutMs": 120000 - }, - "antigravity": { - "baseUrls": [ - "https://daily-cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com" - ], - "format": "antigravity", - "headers": { - "User-Agent": "antigravity/1.107.0 darwin/arm64" - }, - "clientId": "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", - "clientSecret": "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" - }, - "openrouter": { - "baseUrl": "https://openrouter.ai/api/v1/chat/completions", - "format": "openai", - "headers": { - "HTTP-Referer": "https://endpoint-proxy.local", - "X-Title": "Endpoint Proxy" + "clientSecret": "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", + "auth": { + "apiKey": { + "header": "x-goog-api-key", + "scheme": "raw" + }, + "oauth": { + "header": "Authorization", + "scheme": "bearer" + } } }, - "openai": { - "baseUrl": "https://api.openai.com/v1/chat/completions", - "format": "openai" - }, - "vercel-ai-gateway": { - "baseUrl": "https://ai-gateway.vercel.sh/v1/chat/completions", - "format": "openai", - "retry": { - "429": 2 - } - }, - "glm": { - "baseUrl": "https://api.z.ai/api/anthropic/v1/messages", - "format": "claude", - "headers": { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - } - }, - "glm-cn": { - "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", - "format": "openai", - "headers": {} - }, - "kimi": { - "baseUrl": "https://api.kimi.com/coding/v1/messages", - "format": "claude", - "headers": { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - } - }, - "minimax": { - "baseUrl": "https://api.minimax.io/anthropic/v1/messages", - "format": "claude", - "headers": { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - } - }, - "minimax-cn": { - "baseUrl": "https://api.minimaxi.com/anthropic/v1/messages", - "format": "claude", - "headers": { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - } - }, - "alicode": { - "baseUrl": "https://coding.dashscope.aliyuncs.com/v1/chat/completions", - "format": "openai", - "headers": {} - }, - "alicode-intl": { - "baseUrl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", - "format": "openai", - "headers": {} - }, - "volcengine-ark": { - "baseUrl": "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", - "format": "openai", - "headers": {} - }, - "byteplus": { - "baseUrl": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions", - "format": "openai", - "headers": {} - }, "github": { "baseUrl": "https://api.githubcopilot.com/chat/completions", "responsesUrl": "https://api.githubcopilot.com/responses", - "format": "openai", "headers": { "copilot-integration-id": "vscode-chat", "editor-version": "vscode/1.110.0", @@ -172,7 +319,253 @@ "Accept": "application/json", "Content-Type": "application/json" }, - "clientId": "Iv1.b507a08c87ecfe98" + "copilot": { + "vscodeVersion": "1.110.0", + "chatVersion": "0.38.0", + "userAgent": "GitHubCopilotChat/0.38.0", + "apiVersion": "2025-04-01" + }, + "usage": { + "url": "https://api.github.com/copilot_internal/user" + }, + "format": "openai", + "clientId": "Iv1.b507a08c87ecfe98", + "tokenUrl": "https://github.com/login/oauth/access_token" + }, + "gitlab": { + "baseUrl": "https://gitlab.com/api/v4/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + }, + "format": "openai" + }, + "glm-cn": { + "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", + "headers": {}, + "usage": { + "url": "https://open.bigmodel.cn/api/monitor/usage/quota/limit" + }, + "format": "openai" + }, + "glm": { + "baseUrl": "https://api.z.ai/api/anthropic/v1/messages", + "format": "claude", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + }, + "usage": { + "url": "https://api.z.ai/api/monitor/usage/quota/limit" + }, + "transports": [ + { + "format": "openai", + "baseUrl": "https://api.z.ai/api/coding/paas/v4/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "baseUrl": "https://api.z.ai/api/anthropic/v1/messages", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + } + } + ] + }, + "grok-cli": { + "baseUrl": "https://cli-chat-proxy.grok.com/v1/responses", + "format": "openai-responses", + "forceStream": true, + "modelsUrl": "https://cli-chat-proxy.grok.com/v1/models", + "userUrl": "https://cli-chat-proxy.grok.com/v1/user", + "billingUrl": "https://cli-chat-proxy.grok.com/v1/billing", + "clientVersion": "0.2.93", + "clientIdentifier": "grok-pager", + "tokenAuth": "xai-grok-cli", + "headers": { + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-identifier": "grok-pager", + "x-grok-client-version": "0.2.93", + "x-authenticateresponse": "authenticate-response" + }, + "compactionAt": 400000, + "retry": { + "429": { + "attempts": 2, + "delayMs": 2000 + }, + "502": { + "attempts": 2, + "delayMs": 1500 + }, + "503": { + "attempts": 2, + "delayMs": 1500 + } + }, + "clientId": "b1a00492-073a-47ea-816f-4c329264a828", + "tokenUrl": "https://auth.x.ai/oauth2/token" + }, + "grok-web": { + "baseUrl": "https://grok.com/rest/app-chat/conversations/new", + "format": "grok-web", + "authType": "cookie" + }, + "groq": { + "baseUrl": "https://api.groq.com/openai/v1/chat/completions", + "validateUrl": "https://api.groq.com/openai/v1/models", + "format": "openai" + }, + "hyperbolic": { + "baseUrl": "https://api.hyperbolic.xyz/v1/chat/completions", + "validateUrl": "https://api.hyperbolic.xyz/v1/models", + "format": "openai" + }, + "iflow": { + "baseUrl": "https://apis.iflow.cn/v1/chat/completions", + "thinkingFormat": "openai", + "headers": { + "User-Agent": "iFlow-Cli" + }, + "format": "openai", + "clientId": "10009311001", + "clientSecret": "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", + "tokenUrl": "https://iflow.cn/oauth/token" + }, + "kilocode": { + "baseUrl": "https://api.kilo.ai/api/openrouter/chat/completions", + "headers": {}, + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer", + "hooks": [ + "kilocodeOrg" + ] + }, + "format": "openai" + }, + "kimchi": { + "baseUrl": "https://llm.kimchi.dev/openai/v1/chat/completions", + "format": "openai", + "headers": { + "User-Agent": "kimchi/0.1.50" + }, + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + "kimi-coding": { + "baseUrl": "https://api.kimi.com/coding/v1/messages", + "format": "claude", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "clientId": "17e5f671-d194-4dfb-9706-5516cb48c098", + "tokenUrl": "https://auth.kimi.com/api/oauth/token", + "refreshUrl": "https://auth.kimi.com/api/oauth/token", + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw", + "hooks": [ + "kimiHeaders" + ] + }, + "transports": [ + { + "format": "openai", + "baseUrl": "https://api.kimi.com/coding/v1/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer", + "hooks": [ + "kimiHeaders" + ] + } + }, + { + "format": "claude", + "baseUrl": "https://api.kimi.com/coding/v1/messages", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw", + "hooks": [ + "kimiHeaders" + ] + } + } + ] + }, + "kimi": { + "baseUrl": "https://api.kimi.com/coding/v1/messages", + "format": "claude", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + }, + "transports": [ + { + "format": "openai", + "baseUrl": "https://api.kimi.com/coding/v1/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "baseUrl": "https://api.kimi.com/coding/v1/messages", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + } + } + ] }, "kiro": { "baseUrl": "https://runtime.us-east-1.kiro.dev/generateAssistantResponse", @@ -193,210 +586,319 @@ "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0" }, "tokenUrl": "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", - "authUrl": "https://prod.us-east-1.auth.desktop.kiro.dev" + "authUrl": "https://prod.us-east-1.auth.desktop.kiro.dev", + "usage": { + "cwHost": "https://codewhisperer.us-east-1.amazonaws.com", + "qHost": "https://q.us-east-1.amazonaws.com", + "limitsPath": "/getUsageLimits" + } }, - "cursor": { - "baseUrl": "https://api2.cursor.sh", - "chatPath": "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", - "format": "cursor", - "headers": { - "connect-accept-encoding": "gzip", - "connect-protocol-version": "1", - "Content-Type": "application/connect+proto", - "User-Agent": "connect-es/1.6.1" - }, - "clientVersion": "3.1.0" + "mimo-free": { + "baseUrl": "https://api.xiaomimimo.com/api/free-ai/openai/chat", + "noAuth": true, + "format": "openai" }, - "kimi-coding": { - "baseUrl": "https://api.kimi.com/coding/v1/messages", + "minimax-cn": { + "baseUrl": "https://api.minimaxi.com/anthropic/v1/messages", "format": "claude", + "urlSuffix": "?beta=true", "headers": { "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" }, - "clientId": "17e5f671-d194-4dfb-9706-5516cb48c098", - "tokenUrl": "https://auth.kimi.com/api/oauth/token", - "refreshUrl": "https://auth.kimi.com/api/oauth/token" - }, - "kilocode": { - "baseUrl": "https://api.kilo.ai/api/openrouter/chat/completions", - "format": "openai", - "headers": {} - }, - "opencode": { - "baseUrl": "https://opencode.ai", - "format": "openai", - "headers": { - "x-opencode-client": "desktop" + "quirks": { + "dropOutputConfig": true }, - "noAuth": true - }, - "cline": { - "baseUrl": "https://api.cline.bot/api/v1/chat/completions", - "format": "openai", - "headers": { - "HTTP-Referer": "https://cline.bot", - "X-Title": "Cline" + "reasoningInject": { + "scope": "all" }, - "tokenUrl": "https://api.cline.bot/api/v1/auth/token", - "refreshUrl": "https://api.cline.bot/api/v1/auth/refresh" + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + }, + "usage": { + "urls": [ + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" + ] + }, + "transports": [ + { + "format": "openai", + "baseUrl": "https://api.minimaxi.com/v1/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "baseUrl": "https://api.minimaxi.com/anthropic/v1/messages", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + } + } + ] }, - "nvidia": { - "baseUrl": "https://integrate.api.nvidia.com/v1/chat/completions", - "format": "openai" - }, - "anthropic": { - "baseUrl": "https://api.anthropic.com/v1/messages", + "minimax": { + "baseUrl": "https://api.minimax.io/anthropic/v1/messages", "format": "claude", + "urlSuffix": "?beta=true", "headers": { "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - } - }, - "deepseek": { - "baseUrl": "https://api.deepseek.com/chat/completions", - "format": "openai" - }, - "commandcode": { - "baseUrl": "https://api.commandcode.ai/alpha/generate", - "format": "commandcode", - "headers": { - "x-command-code-version": "0.25.7", - "x-cli-environment": "cli" - } - }, - "groq": { - "baseUrl": "https://api.groq.com/openai/v1/chat/completions", - "format": "openai" - }, - "xai": { - "baseUrl": "https://api.x.ai/v1/chat/completions", - "responsesUrl": "https://api.x.ai/v1/responses", - "format": "openai", - "clientId": "b1a00492-073a-47ea-816f-4c329264a828", - "tokenUrl": "https://auth.x.ai/oauth2/token", - "refreshUrl": "https://auth.x.ai/oauth2/token" + }, + "quirks": { + "dropOutputConfig": true + }, + "reasoningInject": { + "scope": "all" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + }, + "usage": { + "urls": [ + "https://www.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains" + ] + }, + "transports": [ + { + "format": "openai", + "baseUrl": "https://api.minimax.io/v1/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "baseUrl": "https://api.minimax.io/anthropic/v1/messages", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + } + } + ] }, "mistral": { "baseUrl": "https://api.mistral.ai/v1/chat/completions", + "validateUrl": "https://api.mistral.ai/v1/models", + "quirks": { + "dropClientMetadata": true + }, "format": "openai" }, - "perplexity": { - "baseUrl": "https://api.perplexity.ai/chat/completions", - "format": "openai" - }, - "together": { - "baseUrl": "https://api.together.xyz/v1/chat/completions", - "format": "openai" - }, - "fireworks": { - "baseUrl": "https://api.fireworks.ai/inference/v1/chat/completions", - "format": "openai" - }, - "cerebras": { - "baseUrl": "https://api.cerebras.ai/v1/chat/completions", - "format": "openai" - }, - "cohere": { - "baseUrl": "https://api.cohere.ai/v1/chat/completions", - "format": "openai" - }, - "nebius": { - "baseUrl": "https://api.studio.nebius.ai/v1/chat/completions", - "format": "openai" - }, - "siliconflow": { - "baseUrl": "https://api.siliconflow.com/v1/chat/completions", - "format": "openai" - }, - "hyperbolic": { - "baseUrl": "https://api.hyperbolic.xyz/v1/chat/completions", - "format": "openai" - }, - "deepgram": { - "baseUrl": "https://api.deepgram.com/v1/listen", - "format": "openai" - }, - "assemblyai": { - "baseUrl": "https://api.assemblyai.com/v1/audio/transcriptions", + "mmf": { + "baseUrl": "https://api.xiaomimimo.com/api/free-ai/openai/chat", + "noAuth": true, "format": "openai" }, "nanobanana": { "baseUrl": "https://api.nanobananaapi.ai/v1/chat/completions", + "validateUrl": "https://api.nanobananaapi.ai/v1/models", "format": "openai" }, - "chutes": { - "baseUrl": "https://llm.chutes.ai/v1/chat/completions", + "nebius": { + "baseUrl": "https://api.studio.nebius.ai/v1/chat/completions", + "validateUrl": "https://api.studio.nebius.ai/v1/models", "format": "openai" }, - "ollama": { - "baseUrl": "https://ollama.com/api/chat", - "format": "ollama" + "nvidia": { + "baseUrl": "https://integrate.api.nvidia.com/v1/chat/completions", + "validateUrl": "https://integrate.api.nvidia.com/v1/models", + "format": "openai" }, "ollama-local": { "baseUrl": "http://localhost:11434/api/chat", "format": "ollama" }, - "vertex": { - "baseUrl": "https://aiplatform.googleapis.com", - "format": "vertex" + "ollama": { + "baseUrl": "https://ollama.com/api/chat", + "validateUrl": "https://ollama.com/api/tags", + "format": "ollama" }, - "vertex-partner": { - "baseUrl": "https://aiplatform.googleapis.com", - "format": "openai" - }, - "gitlab": { - "baseUrl": "https://gitlab.com/api/v4/chat/completions", - "format": "openai" - }, - "codebuddy-cn": { - "baseUrl": "https://copilot.tencent.com/v2/chat/completions", + "openai": { + "baseUrl": "https://api.openai.com/v1/chat/completions", + "forceStream": true, "format": "openai" }, "opencode-go": { "baseUrl": "https://opencode.ai/zen/go/v1/chat/completions", - "format": "openai", - "headers": {} + "headers": {}, + "format": "openai" }, - "grok-web": { - "baseUrl": "https://grok.com/rest/app-chat/conversations/new", - "format": "grok-web", - "authType": "cookie" + "opencode": { + "baseUrl": "https://opencode.ai", + "headers": { + "x-opencode-client": "desktop" + }, + "noAuth": true, + "format": "openai" + }, + "openrouter": { + "baseUrl": "https://openrouter.ai/api/v1/chat/completions", + "thinkingFormat": "openai", + "headers": { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy" + }, + "format": "openai" }, "perplexity-web": { "baseUrl": "https://www.perplexity.ai/rest/sse/perplexity_ask", "format": "perplexity-web", "authType": "cookie" }, - "azure": { - "baseUrl": "", - "format": "openai", - "headers": {} + "perplexity": { + "baseUrl": "https://api.perplexity.ai/chat/completions", + "validateUrl": "https://api.perplexity.ai/models", + "format": "openai" }, - "cloudflare-ai": { - "baseUrl": "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions", + "qoder": { + "baseUrl": "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", + "headers": {}, + "timeoutMs": 120000, + "stallTimeoutMs": 120000, + "usage": { + "url": "https://openapi.qoder.sh/api/v2/quota/usage" + }, + "format": "openai" + }, + "qwen": { + "baseUrl": "https://portal.qwen.ai/v1/chat/completions", + "format": "openai", + "clientId": "f0304373b74a44d2b584a3fb70ca9e56", + "tokenUrl": "https://chat.qwen.ai/api/v1/oauth2/token" + }, + "siliconflow": { + "baseUrl": "https://api.siliconflow.com/v1/chat/completions", + "validateUrl": "https://api.siliconflow.com/v1/models", + "thinkingFormat": "openai", + "format": "openai" + }, + "together": { + "baseUrl": "https://api.together.xyz/v1/chat/completions", + "validateUrl": "https://api.together.xyz/v1/models", + "format": "openai" + }, + "venice": { + "baseUrl": "https://api.venice.ai/api/v1/chat/completions", + "validateUrl": "https://api.venice.ai/api/v1/models", + "thinkingFormat": "openai", + "format": "openai" + }, + "vercel-ai-gateway": { + "baseUrl": "https://ai-gateway.vercel.sh/v1/chat/completions", + "thinkingFormat": "openai", + "retry": { + "429": 2 + }, + "usage": { + "url": "https://ai-gateway.vercel.sh/v1/credits" + }, + "format": "openai" + }, + "vertex-partner": { + "baseUrl": "https://aiplatform.googleapis.com", + "format": "openai" + }, + "vertex": { + "baseUrl": "https://aiplatform.googleapis.com", + "format": "vertex" + }, + "volcengine-ark": { + "baseUrl": "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", + "headers": {}, + "format": "openai" + }, + "xai": { + "baseUrl": "https://api.x.ai/v1/chat/completions", + "validateUrl": "https://api.x.ai/v1/models", + "responsesUrl": "https://api.x.ai/v1/responses", + "clientId": "b1a00492-073a-47ea-816f-4c329264a828", + "tokenUrl": "https://auth.x.ai/oauth2/token", + "refreshUrl": "https://auth.x.ai/oauth2/token", "format": "openai" }, "xiaomi-mimo": { "baseUrl": "https://api.xiaomimimo.com/v1/chat/completions", - "format": "openai" - }, - "mimo-free": { - "baseUrl": "https://api.xiaomimimo.com/api/free-ai/openai/chat", + "validateUrl": "https://api.xiaomimimo.com/v1/models", "format": "openai", - "noAuth": true - }, - "mmf": { - "baseUrl": "https://api.xiaomimimo.com/api/free-ai/openai/chat", - "format": "openai", - "noAuth": true + "transports": [ + { + "format": "openai", + "baseUrl": "https://api.xiaomimimo.com/v1/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "baseUrl": "https://api.xiaomimimo.com/anthropic/v1/messages", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + } + } + ] }, "xiaomi-tokenplan": { "baseUrl": "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions", - "format": "openai" - }, - "blackbox": { - "baseUrl": "https://api.blackbox.ai/chat/completions", - "format": "openai" + "regions": { + "sgp": "https://token-plan-sgp.xiaomimimo.com/v1", + "cn": "https://token-plan-cn.xiaomimimo.com/v1", + "ams": "https://token-plan-ams.xiaomimimo.com/v1" + }, + "defaultRegion": "sgp", + "format": "openai", + "transports": [ + { + "format": "openai", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + } + } + ] } -} +} \ No newline at end of file diff --git a/tests/__baseline__/verify-alias.mjs b/tests/__baseline__/verify-alias.mjs index 939730f2..56caca53 100644 --- a/tests/__baseline__/verify-alias.mjs +++ b/tests/__baseline__/verify-alias.mjs @@ -16,7 +16,8 @@ const ALIAS_TOKENS = [ "mistral","pplx","perplexity","together","fireworks","cerebras","cohere","nvidia","nebius", "siliconflow","hyp","hyperbolic","dg","deepgram","aai","assemblyai","nb","nanobanana","ch", "chutes","ark","volcengine-ark","byteplus","bpm","cursor","vx","vertex","vxp","vertex-partner", - "gw","grok-web","pw","perplexity-web","mimo","xiaomi-mimo","xmtp","xiaomi-tokenplan","cf", + "gw","grok-web","gcli","gb","grok-build","grok-cli","pw","perplexity-web","mimo","xiaomi-mimo", + "xmtp","xiaomi-tokenplan","cf", "cloudflare-ai","fal","fal-ai","stability","stability-ai","bfl","black-forest-labs","recraft", "topaz","runway","runwayml","jina","jina-ai","polly","aws-polly","bb","blackbox", ]; diff --git a/tests/__baseline__/verify-oauth-urls.mjs b/tests/__baseline__/verify-oauth-urls.mjs index 0c3cec76..b0c6a8fa 100644 --- a/tests/__baseline__/verify-oauth-urls.mjs +++ b/tests/__baseline__/verify-oauth-urls.mjs @@ -19,6 +19,8 @@ const resolved = { iflow: PROVIDERS.iflow?.tokenUrl, kiro: PROVIDERS.kiro?.tokenUrl, xai: PROVIDERS.xai?.tokenUrl, + // Grok CLI injects oauth.tokenUrl onto PROVIDERS via OAUTH_INJECT_FIELDS + "grok-cli": PROVIDERS["grok-cli"]?.tokenUrl, cline: PROVIDERS.cline?.tokenUrl, "kimi-coding": PROVIDERS["kimi-coding"]?.tokenUrl, }, @@ -31,6 +33,7 @@ const resolved = { cline: PROVIDERS.cline?.refreshUrl, "kimi-coding": PROVIDERS["kimi-coding"]?.refreshUrl, xai: PROVIDERS.xai?.refreshUrl, + "grok-cli": PROVIDERS["grok-cli"]?.tokenUrl, }, clientIds: { claude: PROVIDERS.claude?.clientId, @@ -38,6 +41,7 @@ const resolved = { qwen: PROVIDERS.qwen?.clientId, iflow: PROVIDERS.iflow?.clientId, "kimi-coding": PROVIDERS["kimi-coding"]?.clientId, + "grok-cli": PROVIDERS["grok-cli"]?.clientId, }, }; const current = JSON.parse(JSON.stringify(resolved)); diff --git a/tests/unit/grok-cli-executor.test.js b/tests/unit/grok-cli-executor.test.js new file mode 100644 index 00000000..e7c90522 --- /dev/null +++ b/tests/unit/grok-cli-executor.test.js @@ -0,0 +1,288 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + GrokCliExecutor, + countGrokCliUserTurns, + resolveGrokCliTurnIdx, + _resetGrokCliTurnStore, +} from "../../open-sse/executors/grok-cli.js"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.js"; +import { PROVIDERS, PROVIDER_OAUTH, PROVIDER_MODELS } from "../../open-sse/providers/index.js"; +import { getModelUpstreamId } from "../../open-sse/config/providerModels.js"; +import { resolveProviderAlias } from "../../open-sse/services/model.js"; +import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers.js"; + +describe("grok-cli registry", () => { + it("registers transport + oauth + models", () => { + const cfg = PROVIDERS["grok-cli"]; + expect(cfg).toBeTruthy(); + expect(cfg.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1/responses"); + expect(cfg.format).toBe("openai-responses"); + expect(cfg.forceStream).toBe(true); + expect(cfg.tokenAuth).toBe("xai-grok-cli"); + + const oauth = PROVIDER_OAUTH["grok-cli"]; + expect(oauth.clientId).toBe("b1a00492-073a-47ea-816f-4c329264a828"); + expect(oauth.deviceCodeUrl).toContain("auth.x.ai"); + expect(oauth.scope).toContain("grok-cli:access"); + expect(oauth.scope).toContain("conversations:write"); + expect(oauth.referrer).toBe("grok-build"); + + expect(PROVIDER_MODELS.gcli?.some((m) => m.id === "grok-4.5")).toBe(true); + }); + + it("is listed as oauth provider for dashboard", () => { + expect(OAUTH_PROVIDERS["grok-cli"]).toBeTruthy(); + expect(OAUTH_PROVIDERS["grok-cli"].name).toMatch(/Grok CLI/i); + }); + + it("resolves aliases to provider id", () => { + expect(resolveProviderAlias("gcli")).toBe("grok-cli"); + expect(resolveProviderAlias("gb")).toBe("grok-cli"); + expect(resolveProviderAlias("grok-build")).toBe("grok-cli"); + expect(resolveProviderAlias("grok-cli")).toBe("grok-cli"); + }); + + it("maps effort virtual models to upstream grok-4.5", () => { + expect(getModelUpstreamId("gcli", "grok-4.5-high")).toBe("grok-4.5"); + expect(getModelUpstreamId("gcli", "grok-4.5-medium")).toBe("grok-4.5"); + expect(getModelUpstreamId("gcli", "grok-4.5-low")).toBe("grok-4.5"); + expect(getModelUpstreamId("gcli", "grok-4.5")).toBe("grok-4.5"); + }); +}); + +describe("GrokCliExecutor", () => { + let executor; + + beforeEach(() => { + _resetGrokCliTurnStore(); + executor = new GrokCliExecutor(); + }); + + it("is registered on executor map (id + aliases)", () => { + expect(hasSpecializedExecutor("grok-cli")).toBe(true); + expect(getExecutor("grok-cli")).toBeInstanceOf(GrokCliExecutor); + expect(getExecutor("gcli")).toBeInstanceOf(GrokCliExecutor); + expect(getExecutor("gb")).toBeInstanceOf(GrokCliExecutor); + }); + + it("buildUrl points at cli-chat-proxy responses", () => { + expect(executor.buildUrl()).toBe("https://cli-chat-proxy.grok.com/v1/responses"); + }); + + it("buildHeaders sets CLI fingerprint + session headers", () => { + executor._currentSessionId = "sess-abc"; + executor._currentReqId = "req-xyz"; + executor._agentId = "agent-1"; + executor._currentModel = "grok-4.5"; + executor._currentTurnIdx = 3; + + const headers = executor.buildHeaders( + { + accessToken: "tok_test", + providerSpecificData: { email: "u@example.com", userId: "uid-1" }, + }, + true + ); + + expect(headers.Authorization).toBe("Bearer tok_test"); + expect(headers.Accept).toBe("text/event-stream"); + expect(headers["x-xai-token-auth"]).toBe("xai-grok-cli"); + expect(headers["x-grok-client-identifier"]).toBe("grok-pager"); + expect(headers["x-grok-client-version"]).toBe("0.2.93"); + expect(headers["x-grok-session-id"]).toBe("sess-abc"); + expect(headers["x-grok-conv-id"]).toBe("sess-abc"); + expect(headers["x-grok-req-id"]).toBe("req-xyz"); + expect(headers["x-grok-turn-idx"]).toBe("3"); + expect(headers["x-grok-agent-id"]).toBe("agent-1"); + expect(headers["x-grok-model-override"]).toBe("grok-4.5"); + expect(headers["x-compaction-at"]).toBe("400000"); + expect(headers["x-email"]).toBe("u@example.com"); + expect(headers["x-userid"]).toBe("uid-1"); + expect(headers["x-authenticateresponse"]).toBe("authenticate-response"); + }); + + it("buildHeaders falls back to top-level email/userId (OAuth mapTokens shape)", () => { + executor._currentSessionId = "sess-top"; + executor._currentReqId = "req-top"; + + const headers = executor.buildHeaders( + { + accessToken: "tok_test", + email: "top@example.com", + // userId only top-level; psd has neither email nor userId + providerSpecificData: { authMethod: "device_code" }, + }, + true + ); + + expect(headers["x-email"]).toBe("top@example.com"); + expect(headers["x-userid"]).toBeUndefined(); + }); + + it("transformRequest normalizes Responses body like official CLI", () => { + const body = { + model: "grok-4.5-high", + messages: [{ role: "user", content: "hi" }], + stream: false, + tools: [ + { + type: "function", + function: { + name: "run_terminal_command", + description: "Run bash", + parameters: { type: "object", properties: { command: { type: "string" } } }, + }, + }, + { type: "web_search" }, + { type: "x_search" }, + ], + temperature: 0.7, + max_tokens: 100, + user: "cursor-user", + }; + + // Simulate translator already converting messages→input; also test messages fallback + const out = executor.transformRequest("grok-4.5-high", { ...body }, true, { + connectionId: "conn-1", + }); + + expect(out.model).toBe("grok-4.5"); + expect(out.stream).toBe(true); + expect(out.store).toBe(false); + expect(out.include).toContain("reasoning.encrypted_content"); + expect(out.reasoning).toEqual({ effort: "high", summary: "concise" }); + expect(out.messages).toBeUndefined(); + expect(out.max_tokens).toBeUndefined(); + expect(out.user).toBeUndefined(); + expect(Array.isArray(out.input)).toBe(true); + expect(out.input.length).toBeGreaterThan(0); + expect(executor._currentTurnIdx).toBe(1); + + // tools flattened + hosted tools kept + expect(out.tools).toHaveLength(3); + expect(out.tools[0]).toMatchObject({ + type: "function", + name: "run_terminal_command", + }); + expect(out.tools[0].parameters).toBeTruthy(); + expect(out.tools[0].function).toBeUndefined(); + expect(out.tools[1]).toEqual({ type: "web_search" }); + expect(out.tools[2]).toEqual({ type: "x_search" }); + }); + + it("transformRequest keeps role:system (HAR parity) and strips server ids", () => { + const body = { + model: "grok-4.5", + input: [ + { type: "message", role: "system", content: "You are Grok" }, + { type: "message", role: "user", content: "hi", id: "msg_server_id" }, + { type: "item_reference", id: "rs_abc" }, + "rs_should_drop", + ], + reasoning_effort: "medium", + }; + + const out = executor.transformRequest("grok-4.5", body, true, { connectionId: "c1" }); + expect(out.input).toHaveLength(2); + // Official CLI sends system, not developer (Codex converts; Grok does not) + expect(out.input[0].role).toBe("system"); + expect(out.input[1].id).toBeUndefined(); + expect(out.reasoning.effort).toBe("medium"); + }); + + it("increments x-grok-turn-idx from user-message count and stays monotonic", () => { + const creds = { + connectionId: "turn-conn", + rawHeaders: { "x-session-id": "stable-session-xyz" }, + }; + + // Turn 1: one user message + executor.transformRequest( + "grok-4.5", + { + model: "grok-4.5", + input: [ + { type: "message", role: "system", content: "sys" }, + { type: "message", role: "user", content: "hi" }, + ], + }, + true, + creds + ); + expect(executor._currentSessionId).toBeTruthy(); + expect(executor._currentTurnIdx).toBe(1); + let headers = executor.buildHeaders({ accessToken: "t" }, true); + expect(headers["x-grok-turn-idx"]).toBe("1"); + expect(headers["x-grok-session-id"]).toBe(executor._currentSessionId); + expect(headers["x-grok-conv-id"]).toBe(executor._currentSessionId); + + const sessionId = executor._currentSessionId; + + // Turn 2: full history with two user messages + executor.transformRequest( + "grok-4.5", + { + model: "grok-4.5", + input: [ + { type: "message", role: "system", content: "sys" }, + { type: "message", role: "user", content: "hi" }, + { type: "message", role: "assistant", content: "hello" }, + { type: "message", role: "user", content: "next" }, + ], + }, + true, + creds + ); + expect(executor._currentSessionId).toBe(sessionId); + expect(executor._currentTurnIdx).toBe(2); + headers = executor.buildHeaders({ accessToken: "t" }, true); + expect(headers["x-grok-turn-idx"]).toBe("2"); + + // Same session, payload that only has 1 user msg (delta-style client) must not go backwards + executor.transformRequest( + "grok-4.5", + { + model: "grok-4.5", + input: [{ type: "message", role: "user", content: "only latest" }], + }, + true, + creds + ); + expect(executor._currentTurnIdx).toBe(2); + }); + + it("countGrokCliUserTurns / resolveGrokCliTurnIdx helpers", () => { + expect(countGrokCliUserTurns(null)).toBe(1); + expect( + countGrokCliUserTurns([ + { type: "message", role: "system", content: "s" }, + { type: "message", role: "user", content: "a" }, + { type: "message", role: "assistant", content: "b" }, + { type: "message", role: "user", content: "c" }, + ]) + ).toBe(2); + + expect(resolveGrokCliTurnIdx("s1", [{ role: "user", type: "message", content: "a" }])).toBe(1); + expect( + resolveGrokCliTurnIdx("s1", [ + { role: "user", type: "message", content: "a" }, + { role: "user", type: "message", content: "b" }, + ]) + ).toBe(2); + // monotonic + expect(resolveGrokCliTurnIdx("s1", [{ role: "user", type: "message", content: "a" }])).toBe(2); + }); + + it("parseError surfaces 402 spending-limit", () => { + const err = executor.parseError( + { status: 402 }, + JSON.stringify({ + code: "personal-team-blocked:spending-limit", + error: "You have run out of credits", + }) + ); + expect(err.status).toBe(402); + expect(err.code).toBe("personal-team-blocked:spending-limit"); + expect(err.message).toMatch(/credits/i); + }); +}); diff --git a/tests/unit/grok-cli-oauth-probe.test.js b/tests/unit/grok-cli-oauth-probe.test.js new file mode 100644 index 00000000..5cda4cdb --- /dev/null +++ b/tests/unit/grok-cli-oauth-probe.test.js @@ -0,0 +1,50 @@ +/** + * Grok CLI connection-test semantics: 402 spending-limit is soft success (auth OK). + */ +import { describe, it, expect } from "vitest"; +import { classifyOAuthProbeResult } from "../../src/app/api/providers/[id]/test/testUtils.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; + +const GROK_CLI_PROBE = { + url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user", + method: "GET", + acceptStatuses: [402], + softFailMessage: { + 402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.", + }, +}; + +describe("classifyOAuthProbeResult (grok-cli)", () => { + it("treats 200 as hard success", () => { + const r = classifyOAuthProbeResult({ ok: true, status: 200 }, GROK_CLI_PROBE, ""); + expect(r).toEqual({ valid: true, error: null, soft: false }); + }); + + it("treats 402 spending-limit as soft success (connected, out of credits)", () => { + const body = JSON.stringify({ + code: "personal-team-blocked:spending-limit", + error: "You have run out of credits", + }); + const r = classifyOAuthProbeResult({ ok: false, status: 402 }, GROK_CLI_PROBE, body); + expect(r.valid).toBe(true); + expect(r.soft).toBe(true); + expect(r.error).toMatch(/credits|SuperGrok|spending/i); + }); + + it("treats 401 as hard auth failure", () => { + const r = classifyOAuthProbeResult({ ok: false, status: 401 }, GROK_CLI_PROBE, "unauthorized"); + expect(r).toEqual({ valid: false, error: "Token invalid or revoked", soft: false }); + }); + + it("treats 403 as access denied", () => { + const r = classifyOAuthProbeResult({ ok: false, status: 403 }, GROK_CLI_PROBE, ""); + expect(r.valid).toBe(false); + expect(r.error).toMatch(/Access denied/i); + }); + + it("Codex-style acceptStatuses 400 stays silent success (no soft warning)", () => { + const codex = { acceptStatuses: [400] }; + const r = classifyOAuthProbeResult({ ok: false, status: 400 }, codex, "bad request"); + expect(r).toEqual({ valid: true, error: null, soft: false }); + }); +}); diff --git a/tests/unit/grok-cli-usage.test.js b/tests/unit/grok-cli-usage.test.js new file mode 100644 index 00000000..49bebb7a --- /dev/null +++ b/tests/unit/grok-cli-usage.test.js @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { parseGrokCliBilling } from "../../open-sse/services/usage/grok-cli.js"; +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const EXHAUSTED_BILLING = { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-07-08T00:00:00+00:00", + end: "2026-07-15T00:00:00+00:00", + }, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + isUnifiedBillingUser: true, + prepaidBalance: { val: 0 }, + topUpMethod: "TOP_UP_METHOD_SAVED_PAYMENT_METHOD", + billingPeriodStart: "2026-07-08T00:00:00+00:00", + billingPeriodEnd: "2026-07-15T00:00:00+00:00", + }, +}; + +const ACTIVE_BILLING = { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-07-08T00:00:00+00:00", + end: "2026-07-15T00:00:00+00:00", + }, + onDemandCap: { val: 100 }, + onDemandUsed: { val: 35 }, + isUnifiedBillingUser: true, + prepaidBalance: { val: 12.5 }, + billingPeriodStart: "2026-07-08T00:00:00+00:00", + billingPeriodEnd: "2026-07-15T00:00:00+00:00", + }, +}; + +const USER_PROFILE = { + userId: "d84768dd-224d-4052-ba49-0d336fa9160c", + email: "user@example.com", + hasGrokCodeAccess: true, + subscriptionTier: null, +}; + +describe("grok-cli registry usage flag", () => { + it("exposes transport.usage urls", () => { + const cfg = PROVIDERS["grok-cli"]; + expect(cfg.usage?.url).toContain("/v1/billing"); + expect(cfg.usage?.userUrl).toContain("/v1/user"); + }); + + it("is listed in USAGE_SUPPORTED_PROVIDERS", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("grok-cli"); + }); +}); + +describe("parseGrokCliBilling", () => { + it("maps on-demand cap/used + prepaid balance", () => { + const parsed = parseGrokCliBilling(ACTIVE_BILLING, USER_PROFILE); + expect(parsed.plan).toBe("Grok Code"); + expect(parsed.quotas["On-demand"]).toMatchObject({ + used: 35, + total: 100, + remainingPercentage: 65, + }); + // Prepaid is remaining-balance style: 0 used of current pot + expect(parsed.quotas.Prepaid).toMatchObject({ + used: 0, + total: 12.5, + remainingPercentage: 100, + }); + expect(parsed.exhausted).toBe(false); + }); + + it("marks depleted free/promo account as exhausted", () => { + const parsed = parseGrokCliBilling(EXHAUSTED_BILLING, USER_PROFILE); + expect(parsed.quotas["On-demand"].remainingPercentage).toBe(0); + expect(parsed.exhausted).toBe(true); + }); + + it("uses subscriptionTier for plan when present", () => { + const parsed = parseGrokCliBilling(ACTIVE_BILLING, { + ...USER_PROFILE, + subscriptionTier: "super_grok", + }); + expect(parsed.plan).toBe("Super Grok"); + }); +}); + +describe("getUsageForProvider(grok-cli)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns normalized quotas from billing + user endpoints", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(ACTIVE_BILLING)) + .mockResolvedValueOnce(jsonResponse(USER_PROFILE)); + + const usage = await getUsageForProvider({ + provider: "grok-cli", + accessToken: "test-token", + providerSpecificData: { + email: "user@example.com", + userId: "d84768dd-224d-4052-ba49-0d336fa9160c", + }, + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Grok Code"); + expect(usage.quotas["On-demand"]).toMatchObject({ + used: 35, + total: 100, + remainingPercentage: 65, + }); + expect(usage.quotas.Prepaid).toMatchObject({ + used: 0, + total: 12.5, + remainingPercentage: 100, + }); + + // Official CLI fingerprint headers + const billingCall = proxyAwareFetch.mock.calls[0]; + expect(billingCall[0]).toContain("/v1/billing"); + expect(billingCall[1].headers.Authorization).toBe("Bearer test-token"); + expect(billingCall[1].headers["x-xai-token-auth"]).toBe("xai-grok-cli"); + expect(billingCall[1].headers["x-userid"]).toBe( + "d84768dd-224d-4052-ba49-0d336fa9160c", + ); + }); + + it("surfaces auth-expired message on 401", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)) + .mockResolvedValueOnce(jsonResponse(USER_PROFILE)); + + const usage = await getUsageForProvider({ + provider: "grok-cli", + accessToken: "expired", + }); + + expect(usage.message).toMatch(/expired|re-authorize/i); + }); + + it("returns depleted on-demand bar without blocking message when cap is zero", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING)) + .mockResolvedValueOnce(jsonResponse(USER_PROFILE)); + + const usage = await getUsageForProvider({ + provider: "grok-cli", + accessToken: "test-token", + }); + + // Dashboard hides QuotaTable when `message` is set — keep message empty + // so the 0% bar still renders for exhausted free/promo accounts. + expect(usage.message).toBeUndefined(); + expect(usage.quotas["On-demand"].remainingPercentage).toBe(0); + expect(usage.quotas["On-demand"].total).toBe(1); + }); +}); + +describe("parseQuotaData(grok-cli)", () => { + it("forwards remainingPercentage for dashboard bars", () => { + const rows = parseQuotaData("grok-cli", { + plan: "Grok Code", + quotas: { + "On-demand": { + used: 35, + total: 100, + remaining: 65, + remainingPercentage: 65, + resetAt: "2026-07-15T00:00:00.000Z", + }, + }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: "On-demand", + used: 35, + total: 100, + remainingPercentage: 65, + }); + }); +}); diff --git a/tests/unit/openai-responses-multiturn.test.js b/tests/unit/openai-responses-multiturn.test.js new file mode 100644 index 00000000..bdc41dce --- /dev/null +++ b/tests/unit/openai-responses-multiturn.test.js @@ -0,0 +1,182 @@ +/** + * Multi-turn continuity for store=false Responses backends (Grok CLI / Codex). + * Prior-turn reasoning (+ encrypted_content) must survive Chat Completions ↔ Responses. + */ +import { describe, it, expect } from "vitest"; +import { + openaiToOpenAIResponsesRequest, + openaiResponsesToOpenAIRequest, +} from "../../open-sse/translator/request/openai-responses.js"; +import { GrokCliExecutor, _resetGrokCliTurnStore } from "../../open-sse/executors/grok-cli.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; + +describe("openai ↔ responses multi-turn reasoning", () => { + it("openai→responses re-emits reasoning item with summary + encrypted_content", () => { + const body = { + model: "grok-4.5", + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: "hello", + reasoning_content: "thinking hard about greeting", + encrypted_content: "enc_blob_turn1", + }, + { role: "user", content: "next" }, + ], + }; + + const out = openaiToOpenAIResponsesRequest("grok-4.5", body, true, null); + expect(out.store).toBe(false); + + const reasoning = out.input.filter((i) => i.type === "reasoning"); + expect(reasoning).toHaveLength(1); + expect(reasoning[0].encrypted_content).toBe("enc_blob_turn1"); + expect(reasoning[0].summary?.[0]?.text).toMatch(/thinking hard/); + + // Order: user → reasoning → assistant → user + const types = out.input.map((i) => i.type || i.role); + expect(types).toEqual(["message", "reasoning", "message", "message"]); + expect(out.input[0].role).toBe("user"); + expect(out.input[2].role).toBe("assistant"); + expect(out.input[3].role).toBe("user"); + }); + + it("accepts reasoning_encrypted_content alias on assistant messages", () => { + const out = openaiToOpenAIResponsesRequest( + "m", + { + messages: [ + { + role: "assistant", + content: "ok", + reasoning_encrypted_content: "alt_enc", + }, + ], + }, + true, + null + ); + expect(out.input.find((i) => i.type === "reasoning")?.encrypted_content).toBe("alt_enc"); + }); + + it("responses→openai attaches reasoning_content + encrypted_content to assistant", () => { + const body = { + model: "grok-4.5", + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "plan A" }], + encrypted_content: "enc_xyz", + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "hello" }], + }, + ], + }; + + const out = openaiResponsesToOpenAIRequest("grok-4.5", body, true, null); + const assistant = out.messages.find((m) => m.role === "assistant"); + expect(assistant).toBeTruthy(); + expect(assistant.reasoning_content).toBe("plan A"); + expect(assistant.encrypted_content).toBe("enc_xyz"); + }); + + it("round-trips encrypted_content through openai → responses → openai", () => { + const original = { + model: "grok-4.5", + messages: [ + { role: "user", content: "q1" }, + { + role: "assistant", + content: "a1", + reasoning_content: "r1", + encrypted_content: "ENC_KEEP_ME", + }, + { role: "user", content: "q2" }, + ], + }; + + const responses = openaiToOpenAIResponsesRequest("grok-4.5", structuredClone(original), true, null); + const back = openaiResponsesToOpenAIRequest("grok-4.5", responses, true, null); + const again = openaiToOpenAIResponsesRequest("grok-4.5", back, true, null); + + const enc = again.input.find((i) => i.type === "reasoning")?.encrypted_content; + expect(enc).toBe("ENC_KEEP_ME"); + }); + + it("translateRequest openai→openai-responses preserves encrypted blob", () => { + const body = { + model: "grok-4.5", + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: "yo", + reasoning_content: "why", + encrypted_content: "blob_via_registry", + }, + { role: "user", content: "go" }, + ], + }; + const out = translateRequest( + "openai", + "openai-responses", + "grok-4.5", + structuredClone(body), + true, + {}, + "grok-cli" + ); + expect(out.input.some((i) => i.type === "reasoning" && i.encrypted_content === "blob_via_registry")).toBe( + true + ); + }); +}); + +describe("GrokCliExecutor multi-turn input", () => { + it("keeps reasoning items (incl. encrypted_content) and strips only server message ids", () => { + _resetGrokCliTurnStore(); + const executor = new GrokCliExecutor(); + const body = { + model: "grok-4.5", + input: [ + { type: "message", role: "system", content: "You are Grok" }, + { type: "message", role: "user", content: "hi", id: "msg_server_prev" }, + { + type: "reasoning", + id: "rs_server_prev", + summary: [{ type: "summary_text", text: "prior plan" }], + encrypted_content: "enc_from_cli", + }, + { type: "message", role: "assistant", content: "hello", id: "msg_server_asst" }, + { type: "message", role: "user", content: "again" }, + ], + include: ["reasoning.encrypted_content"], + }; + + const out = executor.transformRequest("grok-4.5", structuredClone(body), true, { + connectionId: "mt-1", + }); + + const reasoning = out.input.filter((i) => i.type === "reasoning"); + expect(reasoning).toHaveLength(1); + expect(reasoning[0].encrypted_content).toBe("enc_from_cli"); + expect(reasoning[0].summary?.[0]?.text).toBe("prior plan"); + // server id stripped from reasoning item, content kept + expect(reasoning[0].id).toBeUndefined(); + + // system preserved (not developer) + expect(out.input[0].role).toBe("system"); + // message server ids stripped + for (const item of out.input) { + if (item.type === "message") expect(item.id).toBeUndefined(); + } + expect(out.include).toContain("reasoning.encrypted_content"); + expect(out.store).toBe(false); + expect(executor._currentTurnIdx).toBe(2); + }); +}); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index e4d4ac82..75096d78 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -15,7 +15,7 @@ const load = () => import("../../open-sse/services/usage.js"); const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", "qoder", "qwen", "iflow", "ollama", "glm", "glm-cn", - "minimax", "minimax-cn", "vercel-ai-gateway", + "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", ]; describe("usage dispatch", () => {