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 }]
|
||||
|
||||
Reference in New Issue
Block a user