feat(grok-cli): add Grok CLI / Grok Build provider with OAuth device-code flow (#2502)
New OAuth provider routing through cli-chat-proxy.grok.com (OpenAI Responses
API), distinct from xai (api.x.ai) and grok-web (cookie SSO):
- Registry + GrokCliExecutor: Chat Completions -> Responses transform, CLI
fingerprint headers, virtual effort models grok-4.5-{low,medium,high}
- OAuth device-code flow (auth.x.ai) with no-PKCE, shared xAI token refresh
- store=false multi-turn continuity via reasoning encrypted_content
- Quota tracker: on-demand window + prepaid balance on dashboard
- Connection test: 402 spending-limit = soft success (auth OK, out of credits)
- Alias/oauth/provider baselines + unit tests
This commit is contained in:
committed by
decolua
parent
c73c419d09
commit
a11937cdd6
397
open-sse/executors/grok-cli.js
Normal file
397
open-sse/executors/grok-cli.js
Normal file
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
86
open-sse/providers/registry/grok-cli.js
Normal file
86
open-sse/providers/registry/grok-cli.js
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
274
open-sse/services/usage/grok-cli.js
Normal file
274
open-sse/services/usage/grok-cli.js
Normal file
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
public/providers/grok-cli.png
Normal file
BIN
public/providers/grok-cli.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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));
|
||||
|
||||
288
tests/unit/grok-cli-executor.test.js
Normal file
288
tests/unit/grok-cli-executor.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
50
tests/unit/grok-cli-oauth-probe.test.js
Normal file
50
tests/unit/grok-cli-oauth-probe.test.js
Normal file
@@ -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 });
|
||||
});
|
||||
});
|
||||
202
tests/unit/grok-cli-usage.test.js
Normal file
202
tests/unit/grok-cli-usage.test.js
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
182
tests/unit/openai-responses-multiturn.test.js
Normal file
182
tests/unit/openai-responses-multiturn.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user