diff --git a/open-sse/config/grokCli.js b/open-sse/config/grokCli.js new file mode 100644 index 00000000..f2e024e4 --- /dev/null +++ b/open-sse/config/grokCli.js @@ -0,0 +1,10 @@ +export const GROK_CLI_VERSION = "0.2.99"; +export const GROK_CLI_MODEL = "grok-build"; +export const GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; +export const GROK_CLI_CLIENT_IDENTIFIER = "grok-shell"; +export const GROK_CLI_USER_AGENT = `grok-shell/${GROK_CLI_VERSION} (linux; x86_64)`; + +export function supportsGrokCliReasoningEffort(model) { + // ponytail: unknown models omit effort until live metadata reaches dispatch. + return /^grok-4\.5(?:$|-)/.test(String(model || "")); +} diff --git a/open-sse/executors/grok-cli.js b/open-sse/executors/grok-cli.js index 00684d56..59baa2e2 100644 --- a/open-sse/executors/grok-cli.js +++ b/open-sse/executors/grok-cli.js @@ -7,6 +7,12 @@ import { } from "../services/oauthCredentialManager.js"; import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; import { getModelUpstreamId } from "../config/providerModels.js"; +import { + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_VERSION, + supportsGrokCliReasoningEffort, +} from "../config/grokCli.js"; +import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; import { resolveSessionId } from "../utils/sessionManager.js"; import { getConsistentMachineId } from "../shared/machineId.js"; @@ -45,10 +51,18 @@ const RESPONSES_API_ALLOWLIST = new Set([ "prompt_cache_key", ]); -const EFFORT_LEVELS = ["low", "medium", "high"]; +const EFFORT_LEVELS = ["low", "medium", "high", "xhigh"]; +const GROK_CLI_TURN_STORE_MAX = 5000; +const GROK_CLI_NATIVE_ITEM_ID = /^(?:rs|msg|fc)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const GROK_CLI_FREEFORM_TOOL_PARAMETERS = { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], +}; // Per-session last turn index so multi-turn headers never go backwards within this process const sessionTurnStore = new Map(); +let requestTurnStore = new WeakMap(); /** * Count user turns in a Responses `input` array. @@ -72,18 +86,138 @@ export function countGrokCliUserTurns(input) { * 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) { +export function resolveGrokCliTurnIdx(sessionId, input, requestKey = null) { const fromInput = countGrokCliUserTurns(input); if (!sessionId) return fromInput; - const prev = sessionTurnStore.get(sessionId) || 0; - const turn = Math.max(fromInput, prev); - sessionTurnStore.set(sessionId, turn); + + if (requestKey && requestTurnStore.has(requestKey)) { + return requestTurnStore.get(requestKey); + } + + const now = Date.now(); + const existing = sessionTurnStore.get(sessionId); + const prev = existing && now - existing.lastUsed <= MEMORY_CONFIG.sessionTtlMs + ? existing.turn + : 0; + if (existing) sessionTurnStore.delete(sessionId); + + // A new delta-style request advances the turn; retries reuse requestKey. + const turn = prev > 0 ? Math.max(fromInput, prev + (requestKey ? 1 : 0)) : fromInput; + while (sessionTurnStore.size >= GROK_CLI_TURN_STORE_MAX) { + sessionTurnStore.delete(sessionTurnStore.keys().next().value); + } + sessionTurnStore.set(sessionId, { turn, lastUsed: now }); + if (requestKey) requestTurnStore.set(requestKey, turn); return turn; } /** Test helper — clear in-memory turn counters */ export function _resetGrokCliTurnStore() { sessionTurnStore.clear(); + requestTurnStore = new WeakMap(); +} + +export function _getGrokCliTurnStoreSize() { + return sessionTurnStore.size; +} + +export function normalizeGrokCliEffort(value) { + const effort = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (effort === "max") return "xhigh"; + if (EFFORT_LEVELS.includes(effort)) return effort; + return "high"; +} + +export { supportsGrokCliReasoningEffort } from "../config/grokCli.js"; + +export function resolveGrokCliSessionId(credentials, body) { + // ponytail: clients without stable thread metadata share one connection session; + // split further when their wire format exposes a durable conversation id. + const explicitSessionBody = { + prompt_cache_key: body?.prompt_cache_key, + session_id: body?.session_id, + conversation_id: body?.conversation_id, + metadata: body?.metadata, + }; + return resolveSessionId({ + headers: credentials?.rawHeaders, + body: explicitSessionBody, + connectionId: credentials?.connectionId || credentials?.id, + workspaceId: credentials?.providerSpecificData?.workspaceId, + scope: "grok-cli", + }); +} + +function stringifyGrokCliToolOutput(output) { + if (typeof output === "string") return output; + if (output === undefined) return ""; + return JSON.stringify(output); +} + +function isNativeGrokCliItemId(id) { + return typeof id === "string" && GROK_CLI_NATIVE_ITEM_ID.test(id); +} + +function normalizeGrokCliInputItem(item) { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const { internal_chat_message_metadata_passthrough: _metadata, ...clean } = item; + + if (item.type === "reasoning") { + if (!isNativeGrokCliItemId(item.id) || typeof item.encrypted_content !== "string") return null; + return clean; + } + + if (item.type === "custom_tool_call") { + const callId = item.call_id || item.id; + const name = typeof item.name === "string" ? item.name.trim() : ""; + if (!callId || !name) return null; + return { + type: "function_call", + call_id: callId, + name, + arguments: JSON.stringify({ input: stringifyGrokCliToolOutput(item.input ?? item.arguments) }), + }; + } + + if (item.type === "custom_tool_call_output" || item.type === "function_call_output") { + const callId = item.call_id || item.id; + if (!callId) return null; + return { + type: "function_call_output", + call_id: callId, + output: stringifyGrokCliToolOutput(item.output), + }; + } + + if (item.type === "function_call") { + const callId = item.call_id || item.id; + const name = typeof item.name === "string" ? item.name.trim() : ""; + if (!callId || !name) return null; + return { + type: "function_call", + ...(isNativeGrokCliItemId(item.id) ? { id: item.id } : {}), + call_id: callId, + name, + arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}), + ...(typeof item.status === "string" ? { status: item.status } : {}), + }; + } + + return clean; +} + +export function normalizeGrokCliInput(body) { + if (!Array.isArray(body?.input)) return body; + const normalized = body.input.map(normalizeGrokCliInputItem).filter(Boolean); + const callIds = new Set( + normalized + .filter((item) => item?.type === "function_call" && item.call_id) + .map((item) => item.call_id) + ); + body.input = normalized.filter( + (item) => item?.type !== "function_call_output" || callIds.has(item.call_id) + ); + return body; } function stripStoredItemReferences(body) { @@ -92,7 +226,11 @@ function stripStoredItemReferences(body) { 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; + if ( + typeof item.id === "string" && + SERVER_ID_PATTERN.test(item.id) && + !isNativeGrokCliItemId(item.id) + ) delete item.id; } return true; }); @@ -103,15 +241,23 @@ function stripStoredItemReferences(body) { * Keep hosted tools (web_search / x_search) passthrough. */ function normalizeGrokCliTools(body) { - if (!Array.isArray(body.tools)) return; + if (!Array.isArray(body.tools) || body.tools.length === 0) { + delete body.tools; + delete body.tool_choice; + return; + } const validNames = new Set(); + const hostedTypes = 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; + if (HOSTED_TOOL_TYPES.has(type)) { + hostedTypes.add(type); + return true; + } // Nested function shape without type if (!type && tool.function) { // fall through to function flatten below @@ -143,8 +289,9 @@ function normalizeGrokCliTools(body) { : typeof fn?.description === "string" ? fn.description : ""; - const parameters = - tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters) + const parameters = type === "custom" + ? GROK_CLI_FREEFORM_TOOL_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 @@ -155,14 +302,25 @@ function normalizeGrokCliTools(body) { tool.name = name.slice(0, 128); if (description) tool.description = description; tool.parameters = parameters; - validNames.add(name); + validNames.add(tool.name); return true; }); + if (body.tools.length === 0) { + delete body.tools; + delete body.tool_choice; + return; + } + 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; + const choiceType = typeof body.tool_choice.type === "string" ? body.tool_choice.type : ""; + if (choiceType === "function" || choiceType === "custom") { + const rawName = body.tool_choice.name ?? body.tool_choice.function?.name; + const name = typeof rawName === "string" ? rawName.trim().slice(0, 128) : ""; + if (!name || !validNames.has(name)) delete body.tool_choice; + else body.tool_choice = { type: "function", name }; + } else if (!hostedTypes.has(choiceType)) { + delete body.tool_choice; } } } @@ -192,9 +350,9 @@ export class GrokCliExecutor extends BaseExecutor { return this.config.baseUrl; } - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials, log, proxyOptions = null) { if (!credentials?.refreshToken) return null; - return refreshProviderCredentials("grok-cli", credentials, log); + return refreshProviderCredentials("grok-cli", credentials, log, proxyOptions); } needsRefresh(credentials) { @@ -210,13 +368,10 @@ export class GrokCliExecutor extends BaseExecutor { 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"; + this.config.clientIdentifier || headers["x-grok-client-identifier"] || GROK_CLI_CLIENT_IDENTIFIER; headers["x-grok-client-version"] = - this.config.clientVersion || headers["x-grok-client-version"] || "0.2.93"; - headers["x-authenticateresponse"] = "authenticate-response"; + this.config.clientVersion || headers["x-grok-client-version"] || GROK_CLI_VERSION; const sessionId = this._currentSessionId || credentials?.connectionId || crypto.randomUUID(); const reqId = this._currentReqId || crypto.randomUUID(); @@ -231,10 +386,6 @@ export class GrokCliExecutor extends BaseExecutor { // 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 || {}; @@ -267,13 +418,8 @@ export class GrokCliExecutor extends BaseExecutor { 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", - }); + const requestKey = body; + this._currentSessionId = resolveGrokCliSessionId(credentials, body); this._currentReqId = crypto.randomUUID(); this._agentId = credentials?.providerSpecificData?.deviceId || @@ -302,11 +448,12 @@ export class GrokCliExecutor extends BaseExecutor { // Keep role:"system" as-is — official grok-pager HAR sends system, not developer // (Codex converts system→developer; Grok CLI does not). + normalizeGrokCliInput(body); stripStoredItemReferences(body); normalizeGrokCliTools(body); // Turn index after input is finalized (user-message count, monotonic per session) - this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input); + this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input, requestKey); body.stream = true; body.store = false; @@ -325,20 +472,28 @@ export class GrokCliExecutor extends BaseExecutor { body.model = resolvedModel; this._currentModel = resolvedModel; - // Reasoning effort priority: explicit > reasoning_effort > model suffix > default high + // Reasoning effort priority: explicit > reasoning_effort > model suffix > default high. + // grok-build and Composer reject reasoningEffort but still accept summary/encrypted continuity. + const supportsReasoningEffort = supportsGrokCliReasoningEffort(resolvedModel); if (!body.reasoning || typeof body.reasoning !== "object") { - const effort = body.reasoning_effort || modelEffort || "high"; - body.reasoning = { effort, summary: "concise" }; + body.reasoning = { summary: "concise" }; + if (supportsReasoningEffort) { + body.reasoning.effort = normalizeGrokCliEffort(body.reasoning_effort || modelEffort); + } } else { - if (!body.reasoning.effort) { - body.reasoning.effort = body.reasoning_effort || modelEffort || "high"; + if (supportsReasoningEffort) { + body.reasoning.effort = normalizeGrokCliEffort( + body.reasoning.effort || body.reasoning_effort || modelEffort, + ); + } else { + delete body.reasoning.effort; } 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") { + if (body.reasoning && body.reasoning.effort !== "none") { const include = Array.isArray(body.include) ? body.include : []; if (!include.includes("reasoning.encrypted_content")) { include.push("reasoning.encrypted_content"); diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 3206b5c7..594958e8 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -13,6 +13,7 @@ import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { getExecutor } from "../executors/index.js"; +import { supportsGrokCliReasoningEffort } from "../config/grokCli.js"; import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js"; import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js"; import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; @@ -168,7 +169,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const msgN = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || body.messages?.length || body.input?.length || 0; const toolN = translatedBody.tools?.length || body.tools?.length || 0; const fmtStr = passthrough ? `FMT: ${sourceFormat} (passthrough)` : `FMT: ${sourceFormat}→${targetFormat}`; - const think = log.fmtThink?.(extractThinking(translatedBody)); + const showThinking = provider !== "grok-cli" || supportsGrokCliReasoningEffort(model); + const think = showThinking ? log.fmtThink?.(extractThinking(translatedBody)) : null; const acc = credentials?.connectionName || credentials?.connectionId?.slice(0, 8) || "-"; const parts = [ `POST ${clientModel} → ${provider}/${model}`, diff --git a/open-sse/providers/registry/grok-cli.js b/open-sse/providers/registry/grok-cli.js index bc769e1d..403c1abe 100644 --- a/open-sse/providers/registry/grok-cli.js +++ b/open-sse/providers/registry/grok-cli.js @@ -1,13 +1,21 @@ /** * Grok CLI / Grok Build (cli-chat-proxy.grok.com) * - * Source of truth: HAR capture of official grok-shell/grok-pager 0.2.93 + * Source of truth: wire capture of official @xai-official/grok 0.2.99 * talking to https://cli-chat-proxy.grok.com (OpenAI Responses API). * * Distinct from: - * - `xai` → api.x.ai (API key / Grok Build OAuth PKCE) + * - `xai` → api.x.ai (API key / xAI API OAuth PKCE) * - `grok-web` → grok.com web SSO cookie */ +import { + GROK_CLI_BASE_URL, + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_MODEL, + GROK_CLI_USER_AGENT, + GROK_CLI_VERSION, +} from "../../config/grokCli.js"; + export default { id: "grok-cli", priority: 275, @@ -29,32 +37,28 @@ export default { authModes: ["oauth"], hasOAuth: true, thinkingConfig: { - options: ["low", "medium", "high"], + options: ["low", "medium", "high", "xhigh"], defaultMode: "high", }, transport: { - baseUrl: "https://cli-chat-proxy.grok.com/v1/responses", + baseUrl: `${GROK_CLI_BASE_URL}/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", + modelsUrl: `${GROK_CLI_BASE_URL}/models`, + userUrl: `${GROK_CLI_BASE_URL}/user`, + billingUrl: `${GROK_CLI_BASE_URL}/billing`, + clientVersion: GROK_CLI_VERSION, + clientIdentifier: GROK_CLI_CLIENT_IDENTIFIER, 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", + "User-Agent": GROK_CLI_USER_AGENT, + "x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER, + "x-grok-client-version": GROK_CLI_VERSION, }, - // 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", + url: `${GROK_CLI_BASE_URL}/billing?format=credits`, + userUrl: `${GROK_CLI_BASE_URL}/user?include=subscription`, }, retry: { 429: { attempts: 2, delayMs: 2000 }, @@ -63,6 +67,12 @@ export default { }, }, models: [ + { + id: GROK_CLI_MODEL, + name: "Grok Build", + contextLength: 500000, + maxOutputTokens: 64000, + }, { 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" }, diff --git a/open-sse/services/grokCliModels.js b/open-sse/services/grokCliModels.js new file mode 100644 index 00000000..58f5c216 --- /dev/null +++ b/open-sse/services/grokCliModels.js @@ -0,0 +1,127 @@ +import { + GROK_CLI_BASE_URL, + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_MODEL, + GROK_CLI_USER_AGENT, + GROK_CLI_VERSION, +} from "../config/grokCli.js"; +import { refreshProviderCredentials } from "./oauthCredentialManager.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +const MODELS_URL = `${GROK_CLI_BASE_URL}/models`; + +function modelEntries(data) { + const value = Array.isArray(data) ? data : data?.data ?? data?.models ?? data?.results ?? []; + if (Array.isArray(value)) return value.map((item) => [null, item]); + if (value && typeof value === "object") return Object.entries(value); + return []; +} + +export function parseGrokCliModels(data) { + const seen = new Set(); + const models = []; + + for (const [key, raw] of modelEntries(data)) { + const item = typeof raw === "string" ? { id: raw } : raw; + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const id = String( + item.id ?? item.model_id ?? item.modelId ?? item.model ?? item.slug ?? key ?? item.name ?? "", + ).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + const model = { + ...item, + id, + name: item.display_name ?? item.displayName ?? item.name ?? id, + }; + const contextLength = Number( + item.context_length ?? item.contextLength ?? item.context_window ?? item.contextWindow, + ); + const maxOutputTokens = Number(item.max_output_tokens ?? item.maxOutputTokens); + if (Number.isFinite(contextLength) && contextLength > 0) model.contextLength = contextLength; + if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) { + model.maxOutputTokens = maxOutputTokens; + } + if (id === GROK_CLI_MODEL) { + model.contextLength ||= 500000; + model.maxOutputTokens ||= 64000; + } + models.push(model); + } + + return models; +} + +function buildHeaders(accessToken, providerSpecificData = {}) { + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "User-Agent": GROK_CLI_USER_AGENT, + "x-xai-token-auth": "xai-grok-cli", + "x-grok-client-version": GROK_CLI_VERSION, + "x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER, + "x-grok-client-mode": "headless", + }; + const email = providerSpecificData?.email; + const userId = providerSpecificData?.userId || providerSpecificData?.principalId; + if (email) headers["x-email"] = email; + if (userId) headers["x-userid"] = userId; + return headers; +} + +export async function resolveGrokCliModels(credentials, options = {}) { + const { + fetchFn = proxyAwareFetch, + log = console, + proxyOptions = null, + onCredentialsRefreshed, + } = options; + let accessToken = credentials?.accessToken; + if (!accessToken) return { models: [], warning: "Grok CLI access token is missing." }; + + const request = (token) => fetchFn( + MODELS_URL, + { + method: "GET", + headers: buildHeaders(token, credentials?.providerSpecificData), + }, + proxyOptions, + ); + + try { + let response = await request(accessToken); + if ((response.status === 401 || response.status === 403) && credentials?.refreshToken) { + const refreshed = await refreshProviderCredentials( + "grok-cli", + credentials, + log, + proxyOptions, + ); + if (refreshed?.accessToken) { + accessToken = refreshed.accessToken; + try { + await onCredentialsRefreshed?.(refreshed); + } catch (error) { + log?.warn?.("Grok CLI credential persistence failed", error); + } + response = await request(accessToken); + } + } + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + return { + models: [], + warning: `Grok CLI model discovery failed (${response.status})${detail ? `: ${detail.slice(0, 160)}` : ""}`, + }; + } + + const models = parseGrokCliModels(await response.json()); + return models.length + ? { models } + : { models: [], warning: "Grok CLI returned no selectable models." }; + } catch (error) { + return { models: [], warning: `Grok CLI model discovery failed: ${error.message}` }; + } +} diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 6558d707..5b88809c 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -17,6 +17,10 @@ for (const entry of REGISTRY) { for (const a of entry.aliases || []) ALIAS_TO_PROVIDER_ID[a] = entry.id; } +const BUILTIN_MODEL_ALIASES = { + "grok-build": "gcli/grok-build", +}; + /** * Resolve provider alias to provider ID */ @@ -104,7 +108,9 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { : aliasesOrGetter; // Resolve alias - const resolved = resolveModelAliasFromMap(parsed.model, aliases); + const resolved = + resolveModelAliasFromMap(parsed.model, aliases) || + resolveModelAliasFromMap(parsed.model, BUILTIN_MODEL_ALIASES); if (resolved) { return resolved; } diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js index b412b5ee..865baeb0 100644 --- a/open-sse/services/usage/grok-cli.js +++ b/open-sse/services/usage/grok-cli.js @@ -24,6 +24,11 @@ import { proxyAwareFetch } from "../../utils/proxyFetch.js"; import { U, parseResetTime, toFiniteNumber } from "./shared.js"; +import { + GROK_CLI_CLIENT_IDENTIFIER, + GROK_CLI_USER_AGENT, + GROK_CLI_VERSION, +} from "../../config/grokCli.js"; const USAGE = U("grok-cli"); const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; @@ -43,10 +48,11 @@ function buildGrokCliHeaders(accessToken, providerSpecificData = {}) { const headers = { Authorization: `Bearer ${accessToken}`, Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + "User-Agent": GROK_CLI_USER_AGENT, "x-xai-token-auth": "xai-grok-cli", - "x-grok-client-identifier": "grok-pager", - "x-grok-client-version": "0.2.93", + "x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER, + "x-grok-client-version": GROK_CLI_VERSION, + "x-grok-client-mode": "headless", }; const email = psd.email; const userId = psd.userId || psd.principalId; @@ -55,8 +61,18 @@ function buildGrokCliHeaders(accessToken, providerSpecificData = {}) { return headers; } +function subscriptionTier(user, config) { + const rawTier = + user?.subscriptionTier ?? + user?.subscription_tier ?? + user?.subscription?.tier ?? + config?.subscriptionTier ?? + config?.subscription_tier; + return typeof rawTier === "string" ? rawTier.trim() : ""; +} + function resolvePlan(user, config) { - const tier = typeof user?.subscriptionTier === "string" ? user.subscriptionTier.trim() : ""; + const tier = subscriptionTier(user, config); if (tier) { return tier .replace(/[_-]+/g, " ") @@ -105,11 +121,42 @@ export function parseGrokCliBilling(billing, user = null) { const periodEnd = parseResetTime(config.billingPeriodEnd) || + parseResetTime(config.billing_period_end) || parseResetTime(config.currentPeriod?.end) || + parseResetTime(config.resetAt || config.resetsAt || config.periodEnd) || parseResetTime(root.billingPeriodEnd) || + parseResetTime(root.billing_period_end) || + parseResetTime(root.resetAt || root.resetsAt || root.periodEnd) || null; const quotas = {}; + const tier = subscriptionTier(user, config); + const subscriptionAccess = Boolean(tier) && !/^(free|none|null)$/i.test(tier); + + // Current Grok Build responses expose included monthly usage at top level. + const monthlyLimit = unwrapVal( + config.monthlyLimit ?? config.monthly_limit ?? root.monthlyLimit ?? root.monthly_limit, + NaN, + ); + const includedUsed = unwrapVal( + config.includedUsed ?? config.included_used ?? root.includedUsed ?? root.included_used, + NaN, + ); + const totalUsed = unwrapVal( + config.totalUsed ?? config.total_used ?? root.totalUsed ?? root.total_used, + NaN, + ); + if (Number.isFinite(monthlyLimit) && monthlyLimit > 0) { + quotas["Monthly included"] = makeQuota({ + used: Number.isFinite(includedUsed) + ? includedUsed + : Number.isFinite(totalUsed) + ? totalUsed + : 0, + total: monthlyLimit, + resetAt: periodEnd, + }); + } // Primary: on-demand spending window (subscription / promo credits) const onDemandCap = unwrapVal(config.onDemandCap ?? root.onDemandCap, NaN); @@ -121,7 +168,12 @@ export function parseGrokCliBilling(billing, user = null) { total: onDemandCap, resetAt: periodEnd, }); - } else if (Number.isFinite(onDemandCap) && onDemandCap === 0 && Number.isFinite(onDemandUsed)) { + } else if ( + !subscriptionAccess && + 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"] = { @@ -199,6 +251,7 @@ export function parseGrokCliBilling(billing, user = null) { quotas, periodEnd, exhausted, + subscriptionAccess, rawConfig: config, }; } @@ -255,8 +308,9 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null, 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.", + message: parsed.subscriptionAccess + ? "Subscription access is active; Grok does not expose a numeric included quota." + : "Grok Build connected, but no credit allotment was returned. Free promo may be exhausted.", quotas: {}, }; } diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 52f0291b..89b1ca4b 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -8,6 +8,8 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; import { resolveQoderModels } from "open-sse/services/qoderModels.js"; +import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; @@ -369,6 +371,35 @@ const PROVIDER_MODELS_CONFIG = { errorLabel: "Failed to fetch Gemini CLI models" }) }, + "grok-cli": { + customResolver: async (connection) => { + const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}); + const result = await resolveGrokCliModels({ + ...connection, + connectionId: connection.id, + }, { + log: console, + proxyOptions: { + connectionProxyEnabled: proxy.connectionProxyEnabled === true, + connectionProxyUrl: proxy.connectionProxyUrl || "", + connectionNoProxy: proxy.connectionNoProxy || "", + vercelRelayUrl: proxy.vercelRelayUrl || "", + strictProxy: proxy.strictProxy === true, + }, + onCredentialsRefreshed: async (refreshed) => { + await updateProviderCredentials(connection.id, { + ...refreshed, + existingProviderSpecificData: connection.providerSpecificData || {}, + }); + }, + }); + if (result.models.length) return result; + return { + models: getStaticProviderModels("grok-cli"), + warning: result.warning || "Grok CLI returned no live models; using static catalog.", + }; + }, + }, "ollama-local": { customResolver: async (connection) => { const url = `${resolveOllamaLocalHost(connection)}/api/tags`; diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 07a24b6c..6a05da37 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -12,7 +12,9 @@ import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; import { resolveClinepassModels } from "open-sse/services/clinepassModels.js"; +import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; // Per-provider live model resolvers. Each receives a connection record and @@ -71,7 +73,30 @@ const LIVE_MODEL_RESOLVERS = { apiKey: conn.apiKey, }); return result?.models?.length ? { models: result.models } : null; - } + }, + "grok-cli": async (conn) => { + const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {}); + const result = await resolveGrokCliModels({ + ...conn, + connectionId: conn.id, + }, { + log: console, + proxyOptions: { + connectionProxyEnabled: proxy.connectionProxyEnabled === true, + connectionProxyUrl: proxy.connectionProxyUrl || "", + connectionNoProxy: proxy.connectionNoProxy || "", + vercelRelayUrl: proxy.vercelRelayUrl || "", + strictProxy: proxy.strictProxy === true, + }, + onCredentialsRefreshed: async (refreshed) => { + await updateProviderCredentials(conn.id, { + ...refreshed, + existingProviderSpecificData: conn.providerSpecificData || {}, + }); + }, + }); + return result?.models?.length ? { models: result.models } : null; + }, }; const parseOpenAIStyleModels = (data) => { diff --git a/tests/unit/grok-cli-executor.test.js b/tests/unit/grok-cli-executor.test.js index e7c90522..519bf038 100644 --- a/tests/unit/grok-cli-executor.test.js +++ b/tests/unit/grok-cli-executor.test.js @@ -4,11 +4,14 @@ import { countGrokCliUserTurns, resolveGrokCliTurnIdx, _resetGrokCliTurnStore, + _getGrokCliTurnStoreSize, + normalizeGrokCliEffort, + supportsGrokCliReasoningEffort, } 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 { getModelInfoCore, resolveProviderAlias } from "../../open-sse/services/model.js"; import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers.js"; describe("grok-cli registry", () => { @@ -27,7 +30,7 @@ describe("grok-cli registry", () => { 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); + expect(PROVIDER_MODELS.gcli?.some((m) => m.id === "grok-build")).toBe(true); }); it("is listed as oauth provider for dashboard", () => { @@ -42,6 +45,13 @@ describe("grok-cli registry", () => { expect(resolveProviderAlias("grok-cli")).toBe("grok-cli"); }); + it("routes bare grok-build to the subscription provider", async () => { + await expect(getModelInfoCore("grok-build", {})).resolves.toEqual({ + provider: "grok-cli", + model: "grok-build", + }); + }); + 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"); @@ -86,19 +96,19 @@ describe("GrokCliExecutor", () => { 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-xai-token-auth"]).toBeUndefined(); + expect(headers["x-grok-client-identifier"]).toBe("grok-shell"); + expect(headers["x-grok-client-version"]).toBe("0.2.99"); 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-compaction-at"]).toBeUndefined(); expect(headers["x-email"]).toBe("u@example.com"); expect(headers["x-userid"]).toBe("uid-1"); - expect(headers["x-authenticateresponse"]).toBe("authenticate-response"); + expect(headers["x-authenticateresponse"]).toBeUndefined(); }); it("buildHeaders falls back to top-level email/userId (OAuth mapTokens shape)", () => { @@ -190,6 +200,164 @@ describe("GrokCliExecutor", () => { expect(out.reasoning.effort).toBe("medium"); }); + it("normalizes Codex cross-provider tool and reasoning history", () => { + const out = executor.transformRequest("grok-4.5", { + model: "grok-4.5", + input: [ + { type: "message", role: "user", content: "continue" }, + { + type: "reasoning", + id: "rs_07fe505b3114f180016a5698411c448191bdcdcba678464461", + encrypted_content: "openai-ciphertext", + summary: [], + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + }, + { + type: "custom_tool_call", + id: "ctc_openai", + call_id: "call-custom", + name: "exec", + input: "run this", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + }, + { + type: "custom_tool_call_output", + call_id: "call-custom", + output: [{ type: "input_text", text: "first" }, { type: "input_text", text: "second" }], + }, + { + type: "function_call_output", + call_id: "call-function", + output: [{ type: "input_text", text: "function result" }], + }, + ], + tools: [{ type: "custom", name: "exec", description: "Run command" }], + }, true, { connectionId: "cross-provider" }); + + expect(out.input.some((item) => item.type === "reasoning")).toBe(false); + expect(out.input[1]).toEqual({ + type: "function_call", + call_id: "call-custom", + name: "exec", + arguments: JSON.stringify({ input: "run this" }), + }); + expect(out.input[2]).toEqual({ + type: "function_call_output", + call_id: "call-custom", + output: JSON.stringify([{ type: "input_text", text: "first" }, { type: "input_text", text: "second" }]), + }); + expect(out.input.some((item) => item.call_id === "call-function")).toBe(false); + expect(out.tools[0].parameters).toEqual({ + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + }); + }); + + it("stringifies structured outputs and removes orphaned output items", () => { + const out = executor.transformRequest("grok-4.5", { + model: "grok-4.5", + input: [ + { type: "function_call", call_id: "call-array", name: "array_tool", arguments: "{}" }, + { type: "function_call_output", call_id: "call-array", output: [1, 2] }, + { type: "function_call", call_id: "call-null", name: "null_tool", arguments: "{}" }, + { type: "function_call_output", call_id: "call-null", output: null }, + { type: "custom_tool_call", call_id: "call-invalid", input: "missing name" }, + { type: "custom_tool_call_output", call_id: "call-invalid", output: "orphan" }, + ], + }, true, { connectionId: "structured-output" }); + + const outputs = out.input.filter((item) => item.type === "function_call_output"); + expect(outputs).toEqual([ + { type: "function_call_output", call_id: "call-array", output: "[1,2]" }, + { type: "function_call_output", call_id: "call-null", output: "null" }, + ]); + expect(out.input.some((item) => item.call_id === "call-invalid")).toBe(false); + }); + + it("preserves native Grok encrypted reasoning and item ids", () => { + const reasoningId = "rs_3e3f6187-892a-96db-893b-904eff019e19"; + const messageId = "msg_3e3f6187-892a-96db-893b-904eff019e19"; + const functionId = "fc_3e3f6187-892a-96db-893b-904eff019e19"; + const out = executor.transformRequest("grok-4.5", { + model: "grok-4.5", + input: [ + { + type: "reasoning", + id: reasoningId, + status: "completed", + encrypted_content: "grok-ciphertext", + summary: [], + internal_chat_message_metadata_passthrough: { turn_id: "turn-2" }, + }, + { type: "message", id: messageId, role: "assistant", content: "done" }, + { type: "function_call", id: functionId, call_id: "native-call", name: "wait", arguments: "{}" }, + { type: "function_call_output", call_id: "native-call", output: "done" }, + { type: "message", role: "user", content: "next" }, + ], + }, true, { connectionId: "native-grok" }); + + expect(out.input[0]).toMatchObject({ + type: "reasoning", + id: reasoningId, + encrypted_content: "grok-ciphertext", + }); + expect(out.input[0].internal_chat_message_metadata_passthrough).toBeUndefined(); + expect(out.input[1].id).toBe(messageId); + expect(out.input[2].id).toBe(functionId); + }); + + it("normalizes official effort aliases", () => { + expect(normalizeGrokCliEffort("none")).toBe("high"); + expect(normalizeGrokCliEffort("minimal")).toBe("high"); + expect(normalizeGrokCliEffort("max")).toBe("xhigh"); + expect(normalizeGrokCliEffort("xhigh")).toBe("xhigh"); + expect(normalizeGrokCliEffort("ultra")).toBe("high"); + + const out = executor.transformRequest("grok-4.5", { + model: "grok-4.5", + input: "hi", + reasoning: { effort: "max", summary: "detailed" }, + }, true, { connectionId: "effort-conn" }); + expect(out.reasoning).toEqual({ effort: "xhigh", summary: "detailed" }); + }); + + it("omits reasoning effort for models that reject it", () => { + expect(supportsGrokCliReasoningEffort("grok-4.5")).toBe(true); + expect(supportsGrokCliReasoningEffort("grok-build")).toBe(false); + expect(supportsGrokCliReasoningEffort("grok-composer-2.5-fast")).toBe(false); + + for (const model of ["grok-build", "grok-composer-2.5-fast"]) { + const out = executor.transformRequest(model, { + model, + input: "hi", + reasoning: { effort: "max" }, + }, true, { connectionId: `effort-${model}` }); + expect(out.reasoning).toEqual({ summary: "concise" }); + expect(out.include).toContain("reasoning.encrypted_content"); + } + }); + + it("drops stale tool_choice and normalizes converted custom choices", () => { + const noTools = executor.transformRequest("grok-build", { + model: "grok-build", + input: "hi", + tool_choice: "auto", + }, true, { connectionId: "tools-none" }); + expect(noTools.tool_choice).toBeUndefined(); + + const custom = executor.transformRequest("grok-build", { + model: "grok-build", + input: "hi", + tools: [{ type: "custom", name: "apply_patch", description: "Patch files" }], + tool_choice: { type: "custom", name: "apply_patch" }, + }, true, { connectionId: "tools-custom" }); + expect(custom.tools).toEqual([ + expect.objectContaining({ type: "function", name: "apply_patch" }), + ]); + expect(custom.tool_choice).toEqual({ type: "function", name: "apply_patch" }); + }); + it("increments x-grok-turn-idx from user-message count and stays monotonic", () => { const creds = { connectionId: "turn-conn", @@ -238,7 +406,7 @@ describe("GrokCliExecutor", () => { 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 + // Same session, a new delta-style request advances without relying on full history. executor.transformRequest( "grok-4.5", { @@ -248,7 +416,7 @@ describe("GrokCliExecutor", () => { true, creds ); - expect(executor._currentTurnIdx).toBe(2); + expect(executor._currentTurnIdx).toBe(3); }); it("countGrokCliUserTurns / resolveGrokCliTurnIdx helpers", () => { @@ -273,6 +441,45 @@ describe("GrokCliExecutor", () => { expect(resolveGrokCliTurnIdx("s1", [{ role: "user", type: "message", content: "a" }])).toBe(2); }); + it("keeps fallback session stable when assistant history appears", () => { + const creds = { connectionId: "fallback-conn", rawHeaders: {} }; + executor.transformRequest("grok-build", { + model: "grok-build", + input: [{ type: "message", role: "user", content: "first" }], + }, true, creds); + const firstSession = executor._currentSessionId; + + executor.transformRequest("grok-build", { + model: "grok-build", + input: [ + { type: "message", role: "user", content: "first" }, + { type: "message", role: "assistant", content: "x".repeat(100) }, + { type: "message", role: "user", content: "second" }, + ], + }, true, creds); + expect(executor._currentSessionId).toBe(firstSession); + expect(executor._currentTurnIdx).toBe(2); + }); + + it("does not advance turn index when retrying the same request body", () => { + const body = { + model: "grok-build", + input: [{ type: "message", role: "user", content: "retry me" }], + }; + const creds = { connectionId: "retry-conn" }; + executor.transformRequest("grok-build", body, true, creds); + const firstTurn = executor._currentTurnIdx; + executor.transformRequest("grok-build", body, true, creds); + expect(executor._currentTurnIdx).toBe(firstTurn); + }); + + it("bounds per-session turn state", () => { + for (let i = 0; i < 5100; i += 1) { + resolveGrokCliTurnIdx(`session-${i}`, [{ role: "user", content: "hi" }]); + } + expect(_getGrokCliTurnStoreSize()).toBe(5000); + }); + it("parseError surfaces 402 spending-limit", () => { const err = executor.parseError( { status: 402 }, diff --git a/tests/unit/grok-cli-models.test.js b/tests/unit/grok-cli-models.test.js new file mode 100644 index 00000000..3abd3159 --- /dev/null +++ b/tests/unit/grok-cli-models.test.js @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../open-sse/services/oauthCredentialManager.js", () => ({ + refreshProviderCredentials: vi.fn(), +})); + +import { refreshProviderCredentials } from "../../open-sse/services/oauthCredentialManager.js"; +import { + parseGrokCliModels, + resolveGrokCliModels, +} from "../../open-sse/services/grokCliModels.js"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("Grok CLI live models", () => { + beforeEach(() => vi.clearAllMocks()); + + it("normalizes official model metadata", () => { + expect(parseGrokCliModels({ + models: [{ + model_id: "grok-build", + display_name: "Grok Build", + context_window: 500000, + max_output_tokens: 64000, + supported_in_api: false, + }], + })).toEqual([ + expect.objectContaining({ + id: "grok-build", + name: "Grok Build", + contextLength: 500000, + maxOutputTokens: 64000, + supported_in_api: false, + }), + ]); + }); + + it("refreshes and retries through selected proxy", async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce(jsonResponse({ error: "expired" }, 401)) + .mockResolvedValueOnce(jsonResponse({ data: [{ id: "grok-build" }] })); + const onCredentialsRefreshed = vi.fn(); + const proxyOptions = { + connectionProxyEnabled: true, + connectionProxyUrl: "http://proxy.test:8080", + strictProxy: true, + }; + refreshProviderCredentials.mockResolvedValue({ accessToken: "new-token" }); + + const result = await resolveGrokCliModels({ + accessToken: "old-token", + refreshToken: "refresh-token", + providerSpecificData: { email: "user@example.com" }, + }, { fetchFn, proxyOptions, onCredentialsRefreshed }); + + expect(result.models).toEqual([ + expect.objectContaining({ + id: "grok-build", + contextLength: 500000, + maxOutputTokens: 64000, + }), + ]); + expect(refreshProviderCredentials).toHaveBeenCalledWith( + "grok-cli", + expect.any(Object), + expect.anything(), + proxyOptions, + ); + expect(onCredentialsRefreshed).toHaveBeenCalledWith({ accessToken: "new-token" }); + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(fetchFn.mock.calls[0][2]).toBe(proxyOptions); + expect(fetchFn.mock.calls[1][1].headers.Authorization).toBe("Bearer new-token"); + expect(fetchFn.mock.calls[1][1].headers["x-grok-client-version"]).toBe("0.2.99"); + }); +}); diff --git a/tests/unit/grok-cli-usage.test.js b/tests/unit/grok-cli-usage.test.js index 49bebb7a..0c52a2cd 100644 --- a/tests/unit/grok-cli-usage.test.js +++ b/tests/unit/grok-cli-usage.test.js @@ -101,6 +101,35 @@ describe("parseGrokCliBilling", () => { }); expect(parsed.plan).toBe("Super Grok"); }); + + it("does not report paid subscription access as depleted on-demand credit", () => { + const parsed = parseGrokCliBilling(EXHAUSTED_BILLING, { + ...USER_PROFILE, + subscriptionTier: "XPremiumPlus", + }); + expect(parsed.plan).toBe("XPremiumPlus"); + expect(parsed.subscriptionAccess).toBe(true); + expect(parsed.quotas).toEqual({}); + expect(parsed.exhausted).toBe(false); + }); + + it("maps current monthly fields and snake-case subscription tier", () => { + const parsed = parseGrokCliBilling({ + monthlyLimit: { val: 1000 }, + includedUsed: { val: 275 }, + totalUsed: { val: 300 }, + resetAt: "2026-08-01T00:00:00Z", + }, { + subscription_tier: "premium_plus", + }); + expect(parsed.plan).toBe("Premium Plus"); + expect(parsed.quotas["Monthly included"]).toMatchObject({ + used: 275, + total: 1000, + remainingPercentage: 72.5, + resetAt: "2026-08-01T00:00:00.000Z", + }); + }); }); describe("getUsageForProvider(grok-cli)", () => { @@ -140,6 +169,8 @@ describe("getUsageForProvider(grok-cli)", () => { 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-grok-client-version"]).toBe("0.2.99"); + expect(billingCall[1].headers["x-grok-client-identifier"]).toBe("grok-shell"); expect(billingCall[1].headers["x-userid"]).toBe( "d84768dd-224d-4052-ba49-0d336fa9160c", ); @@ -174,6 +205,24 @@ describe("getUsageForProvider(grok-cli)", () => { expect(usage.quotas["On-demand"].remainingPercentage).toBe(0); expect(usage.quotas["On-demand"].total).toBe(1); }); + + it("reports active paid access when provider exposes no numeric quota", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING)) + .mockResolvedValueOnce(jsonResponse({ + ...USER_PROFILE, + subscriptionTier: "XPremiumPlus", + })); + + const usage = await getUsageForProvider({ + provider: "grok-cli", + accessToken: "test-token", + }); + + expect(usage.plan).toBe("XPremiumPlus"); + expect(usage.message).toMatch(/active.*numeric included quota/i); + expect(usage.quotas).toEqual({}); + }); }); describe("parseQuotaData(grok-cli)", () => { diff --git a/tests/unit/openai-responses-multiturn.test.js b/tests/unit/openai-responses-multiturn.test.js index bdc41dce..e08a65ba 100644 --- a/tests/unit/openai-responses-multiturn.test.js +++ b/tests/unit/openai-responses-multiturn.test.js @@ -138,21 +138,21 @@ describe("openai ↔ responses multi-turn reasoning", () => { }); describe("GrokCliExecutor multi-turn input", () => { - it("keeps reasoning items (incl. encrypted_content) and strips only server message ids", () => { + it("keeps native Grok reasoning and item 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: "message", role: "user", content: "hi", id: "msg_3e3f6187-892a-96db-893b-904eff019e19" }, { type: "reasoning", - id: "rs_server_prev", + id: "rs_3e3f6187-892a-96db-893b-904eff019e19", 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: "assistant", content: "hello", id: "msg_4e3f6187-892a-96db-893b-904eff019e19" }, { type: "message", role: "user", content: "again" }, ], include: ["reasoning.encrypted_content"], @@ -166,14 +166,13 @@ describe("GrokCliExecutor multi-turn input", () => { 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(); + expect(reasoning[0].id).toBe("rs_3e3f6187-892a-96db-893b-904eff019e19"); // system preserved (not developer) expect(out.input[0].role).toBe("system"); - // message server ids stripped + // Native Grok IDs are required for encrypted continuity. for (const item of out.input) { - if (item.type === "message") expect(item.id).toBeUndefined(); + if (item.type === "message" && item.id) expect(item.id).toMatch(/^msg_[0-9a-f-]{36}$/); } expect(out.include).toContain("reasoning.encrypted_content"); expect(out.store).toBe(false);