Merge branch 'master' into dev

# Conflicts:
#	src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js
#	src/app/(dashboard)/dashboard/cli-tools/components/index.js
#	src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
#	src/app/api/cli-tools/all-statuses/route.js
#	src/app/api/settings/route.js
#	src/app/api/v1/models/route.js
#	src/shared/constants/cliTools.js
This commit is contained in:
2026-07-17 11:15:18 +07:00
85 changed files with 9230 additions and 669 deletions
+10
View File
@@ -0,0 +1,10 @@
export const GROK_CLI_VERSION = "0.2.99";
export const GROK_CLI_MODEL = "grok-build";
export const GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
export const GROK_CLI_CLIENT_IDENTIFIER = "grok-shell";
export const GROK_CLI_USER_AGENT = `grok-shell/${GROK_CLI_VERSION} (linux; x86_64)`;
export function supportsGrokCliReasoningEffort(model) {
// ponytail: unknown models omit effort until live metadata reaches dispatch.
return /^grok-4\.5(?:$|-)/.test(String(model || ""));
}
+44
View File
@@ -131,6 +131,50 @@ export function resolveKiroThinkingBudget(body, headers, model) {
return null;
}
export function extractKiroEffortLevel(body) {
const effort =
body?.output_config?.effort ??
body?.reasoning_effort ??
(typeof body?.reasoning === "object" ? body.reasoning?.effort : null);
if (typeof effort !== "string") return null;
const normalized = effort.toLowerCase();
if (normalized === "none" || normalized === "off" || normalized === "disabled") return null;
if (normalized === "xhigh" || normalized === "max") return "high";
if (["low", "medium", "high"].includes(normalized)) return normalized;
return null;
}
export function buildKiroAdditionalModelRequestFields(body) {
const effort = extractKiroEffortLevel(body);
if (!effort) return undefined;
// Mirrors Kiro CLI/KAS buildEffortRequestFields("output_config").
return {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort },
};
}
export function supportsKiroAdditionalModelRequestFields(model) {
if (typeof model !== "string") return false;
const normalized = model.toLowerCase().replace(/-/g, ".");
if (!normalized.includes("claude")) return false;
const match = normalized.match(/(?:^|[/.])claude(?:[/.][a-z]+)*[/.](\d+)(?:[/.](\d+))?(?:[/.]|$)/);
if (!match) return false;
const [, majorText, minorText] = match;
const major = Number(majorText);
const minor = minorText === undefined ? null : Number(minorText);
const dateSuffixMinor = minor !== null && minor >= 1000;
// Kiro rejected additionalModelRequestFields on legacy 4.5 models in live smoke.
// Default future Claude/Kiro models to supported so new model releases do not
// need a code allowlist update.
return !(major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor)));
}
export function buildKiroAdditionalModelRequestFieldsForModel(body, model) {
if (!supportsKiroAdditionalModelRequestFields(model)) return undefined;
return buildKiroAdditionalModelRequestFields(body);
}
/**
* Detect whether an inbound request is asking for reasoning / thinking output.
* Thin wrapper over resolveKiroThinkingBudget (single source of truth).
+2
View File
@@ -65,6 +65,8 @@ export const GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS = envMs("GEMINI_NATIVE_TTS_FETCH
export const DEFAULT_MAX_TOKENS = 64000;
export const DEFAULT_MIN_TOKENS = 32000;
export const TOKEN_SAVER_HEADER = "x-9router-token-saver";
// Retry config for 429 responses (legacy - kept for backward compatibility)
export const RETRY_CONFIG = {
maxAttempts: 2,
+123 -34
View File
@@ -4,11 +4,13 @@ import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import { initState } from "../translator/index.js";
import { initState, translateRequest, translateResponse } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
import { SSE_DONE } from "../utils/sseConstants.js";
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
import crypto from "crypto";
export class GithubExecutor extends BaseExecutor {
@@ -17,6 +19,16 @@ export class GithubExecutor extends BaseExecutor {
this.knownCodexModels = new Set();
}
// Claude models get routed to Copilot's Anthropic-native /v1/messages shim (see
// executeWithMessagesEndpoint below) — the only Copilot endpoint that surfaces
// prompt-cache token counts. gpt/gemini/grok models stay on /chat/completions
// (or /responses). Name-pattern check, not a registry field: Copilot's live model
// catalog (services/copilotModels.js) regularly exposes claude-* variants ahead
// of the static registry (registry/github.js).
isClaudeModel(model) {
return /claude/i.test(model || "");
}
buildUrl(model, stream, urlIndex = 0) {
return this.config.baseUrl;
}
@@ -35,47 +47,20 @@ export class GithubExecutor extends BaseExecutor {
"x-request-id": crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
"x-vscode-user-agent-library-version": "electron-fetch",
"X-Initiator": "user",
// Harmless no-op on /chat/completions and /responses; required by /v1/messages.
"anthropic-version": ANTHROPIC_API_VERSION,
"Accept": stream ? "text/event-stream" : "application/json"
};
}
// Sanitize messages for GitHub Copilot /chat/completions endpoint.
// Sanitize messages for GitHub Copilot /chat/completions endpoint (gpt/gemini/grok models —
// claude models never reach this, see execute() below).
// The endpoint only accepts 'text' and 'image_url' content part types.
// Tool-related content (tool_use, tool_result, thinking) must be serialized as text.
sanitizeMessagesForChatCompletions(body) {
if (!body?.messages) return body;
const sanitized = { ...body };
// Handle response_format for Claude models via GitHub
// GitHub's internal translation doesn't respect response_format, so we inject it as a system prompt
// AND prepend a reminder to the last user message for maximum effectiveness
if (body.response_format && body.model?.includes('claude')) {
const responseFormat = body.response_format;
let systemInstruction = '';
if (responseFormat.type === 'json_schema' && responseFormat.json_schema?.schema) {
systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks. Never wrap JSON in triple backticks. Output ONLY the raw JSON object.';
} else if (responseFormat.type === 'json_object') {
systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks.';
}
if (systemInstruction) {
// Add to system message
const systemIdx = body.messages.findIndex(m => m.role === 'system');
if (systemIdx >= 0) {
body.messages[systemIdx].content = systemInstruction + '\n\n' + body.messages[systemIdx].content;
} else {
body.messages.unshift({ role: 'system', content: systemInstruction });
}
// Also prepend to the last user message as a reminder
const lastUserIdx = body.messages.map((m, i) => m.role === 'user' ? i : -1).filter(i => i >= 0).pop();
if (lastUserIdx >= 0) {
const userMsg = body.messages[lastUserIdx];
const userContent = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
userMsg.content = 'Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): ' + userContent;
}
}
}
sanitized.messages = body.messages.map(msg => {
// assistant messages with only tool_calls have content: null — leave as-is
if (!msg.content) return msg;
@@ -138,6 +123,15 @@ export class GithubExecutor extends BaseExecutor {
async execute(options) {
const { model, log } = options;
// Claude models: route to Copilot's Anthropic-native /v1/messages shim — the only
// Copilot endpoint that surfaces prompt-cache token counts for Claude. Detected by
// model NAME (not a registry field): Copilot's live model catalog regularly exposes
// claude-* variants the static registry hasn't caught up with yet (see registry/github.js).
if (this.isClaudeModel(model)) {
log?.debug("GITHUB", `Using /v1/messages route for ${model}`);
return this.executeWithMessagesEndpoint(options);
}
// Only use /responses for models that are explicitly known to need it (e.g. gpt codex models)
// and that the /responses endpoint actually serves (excludes Gemini/Claude, see #1062).
if (this.knownCodexModels.has(model) && this.supportsResponsesEndpoint(model)) {
@@ -145,8 +139,8 @@ export class GithubExecutor extends BaseExecutor {
return this.executeWithResponsesEndpoint(options);
}
// Sanitize messages before sending to /chat/completions
// This handles Claude models on GitHub Copilot which reject non-text/image_url content types
// Sanitize messages before sending to /chat/completions (gpt/gemini/grok — the
// endpoint rejects non-text/image_url content parts).
const sanitizedOptions = {
...options,
body: this.sanitizeMessagesForChatCompletions(options.body)
@@ -251,6 +245,101 @@ export class GithubExecutor extends BaseExecutor {
};
}
// Claude models arrive here OpenAI-shape (chatCore.js targets "openai" for github —
// see the note in execute() above), so we translate to Anthropic-native ourselves.
// This is what makes prepareClaudeRequest() (translator/formats/claude.js) inject
// cache_control — /chat/completions never gets there, so it never sees cache tokens.
async executeWithMessagesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.config.messagesUrl;
const headers = this.buildHeaders(credentials, stream);
// Force stream:true upstream regardless of client preference, same as
// executeWithResponsesEndpoint below — chatCore.js's non-streaming handler already
// knows how to buffer an SSE response into a single JSON reply when the client
// asked for stream:false.
const transformedBody = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, model, body, true, credentials, "github");
// _toolNameMap is internal bookkeeping (see openai-to-claude.js) — chatCore.js
// normally strips it before dispatch and threads it into the response state to
// restore original tool names; we must do the same here, or Anthropic's strict
// schema rejects the extra field with a 400.
const toolNameMap = transformedBody._toolNameMap;
delete transformedBody._toolNameMap;
log?.debug("GITHUB", "Sending translated request to /v1/messages");
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
}, proxyOptions);
if (!response.ok) {
return { response, url, headers, transformedBody };
}
const state = initState(FORMATS.CLAUDE);
state.model = model;
if (toolNameMap) state.toolNameMap = toolNameMap;
const decoder = new TextDecoder();
let buffer = "";
const emitAll = (controller, chunks) => {
for (const c of chunks) {
controller.enqueue(new TextEncoder().encode(formatSSE(c, "openai")));
}
};
const transformStream = new TransformStream({
async transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parsed = parseSSELine(trimmed);
if (!parsed) continue;
if (parsed.done && stream === true) {
controller.enqueue(new TextEncoder().encode(SSE_DONE));
continue;
}
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
},
flush(controller) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
}
}
});
if (!response.body) {
return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody };
}
const convertedStream = response.body.pipeThrough(transformStream);
return {
response: new Response(convertedStream, {
status: response.status,
statusText: response.statusText,
headers: response.headers
}),
url,
headers,
transformedBody
};
}
async refreshCopilotToken(githubAccessToken, log, proxyOptions = null) {
try {
const response = await proxyAwareFetch("https://api.github.com/copilot_internal/v2/token", {
+194 -39
View File
@@ -7,6 +7,12 @@ import {
} from "../services/oauthCredentialManager.js";
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { getModelUpstreamId } from "../config/providerModels.js";
import {
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_VERSION,
supportsGrokCliReasoningEffort,
} from "../config/grokCli.js";
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { getConsistentMachineId } from "../shared/machineId.js";
@@ -45,10 +51,18 @@ const RESPONSES_API_ALLOWLIST = new Set([
"prompt_cache_key",
]);
const EFFORT_LEVELS = ["low", "medium", "high"];
const EFFORT_LEVELS = ["low", "medium", "high", "xhigh"];
const GROK_CLI_TURN_STORE_MAX = 5000;
const GROK_CLI_NATIVE_ITEM_ID = /^(?:rs|msg|fc)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const GROK_CLI_FREEFORM_TOOL_PARAMETERS = {
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
};
// Per-session last turn index so multi-turn headers never go backwards within this process
const sessionTurnStore = new Map();
let requestTurnStore = new WeakMap();
/**
* Count user turns in a Responses `input` array.
@@ -72,18 +86,138 @@ export function countGrokCliUserTurns(input) {
* Prefers user-message count from the payload (full history clients), but never
* decreases vs the last index observed for the same sessionId in this process.
*/
export function resolveGrokCliTurnIdx(sessionId, input) {
export function resolveGrokCliTurnIdx(sessionId, input, requestKey = null) {
const fromInput = countGrokCliUserTurns(input);
if (!sessionId) return fromInput;
const prev = sessionTurnStore.get(sessionId) || 0;
const turn = Math.max(fromInput, prev);
sessionTurnStore.set(sessionId, turn);
if (requestKey && requestTurnStore.has(requestKey)) {
return requestTurnStore.get(requestKey);
}
const now = Date.now();
const existing = sessionTurnStore.get(sessionId);
const prev = existing && now - existing.lastUsed <= MEMORY_CONFIG.sessionTtlMs
? existing.turn
: 0;
if (existing) sessionTurnStore.delete(sessionId);
// A new delta-style request advances the turn; retries reuse requestKey.
const turn = prev > 0 ? Math.max(fromInput, prev + (requestKey ? 1 : 0)) : fromInput;
while (sessionTurnStore.size >= GROK_CLI_TURN_STORE_MAX) {
sessionTurnStore.delete(sessionTurnStore.keys().next().value);
}
sessionTurnStore.set(sessionId, { turn, lastUsed: now });
if (requestKey) requestTurnStore.set(requestKey, turn);
return turn;
}
/** Test helper — clear in-memory turn counters */
export function _resetGrokCliTurnStore() {
sessionTurnStore.clear();
requestTurnStore = new WeakMap();
}
export function _getGrokCliTurnStoreSize() {
return sessionTurnStore.size;
}
export function normalizeGrokCliEffort(value) {
const effort = typeof value === "string" ? value.trim().toLowerCase() : "";
if (effort === "max") return "xhigh";
if (EFFORT_LEVELS.includes(effort)) return effort;
return "high";
}
export { supportsGrokCliReasoningEffort } from "../config/grokCli.js";
export function resolveGrokCliSessionId(credentials, body) {
// ponytail: clients without stable thread metadata share one connection session;
// split further when their wire format exposes a durable conversation id.
const explicitSessionBody = {
prompt_cache_key: body?.prompt_cache_key,
session_id: body?.session_id,
conversation_id: body?.conversation_id,
metadata: body?.metadata,
};
return resolveSessionId({
headers: credentials?.rawHeaders,
body: explicitSessionBody,
connectionId: credentials?.connectionId || credentials?.id,
workspaceId: credentials?.providerSpecificData?.workspaceId,
scope: "grok-cli",
});
}
function stringifyGrokCliToolOutput(output) {
if (typeof output === "string") return output;
if (output === undefined) return "";
return JSON.stringify(output);
}
function isNativeGrokCliItemId(id) {
return typeof id === "string" && GROK_CLI_NATIVE_ITEM_ID.test(id);
}
function normalizeGrokCliInputItem(item) {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const { internal_chat_message_metadata_passthrough: _metadata, ...clean } = item;
if (item.type === "reasoning") {
if (!isNativeGrokCliItemId(item.id) || typeof item.encrypted_content !== "string") return null;
return clean;
}
if (item.type === "custom_tool_call") {
const callId = item.call_id || item.id;
const name = typeof item.name === "string" ? item.name.trim() : "";
if (!callId || !name) return null;
return {
type: "function_call",
call_id: callId,
name,
arguments: JSON.stringify({ input: stringifyGrokCliToolOutput(item.input ?? item.arguments) }),
};
}
if (item.type === "custom_tool_call_output" || item.type === "function_call_output") {
const callId = item.call_id || item.id;
if (!callId) return null;
return {
type: "function_call_output",
call_id: callId,
output: stringifyGrokCliToolOutput(item.output),
};
}
if (item.type === "function_call") {
const callId = item.call_id || item.id;
const name = typeof item.name === "string" ? item.name.trim() : "";
if (!callId || !name) return null;
return {
type: "function_call",
...(isNativeGrokCliItemId(item.id) ? { id: item.id } : {}),
call_id: callId,
name,
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}),
...(typeof item.status === "string" ? { status: item.status } : {}),
};
}
return clean;
}
export function normalizeGrokCliInput(body) {
if (!Array.isArray(body?.input)) return body;
const normalized = body.input.map(normalizeGrokCliInputItem).filter(Boolean);
const callIds = new Set(
normalized
.filter((item) => item?.type === "function_call" && item.call_id)
.map((item) => item.call_id)
);
body.input = normalized.filter(
(item) => item?.type !== "function_call_output" || callIds.has(item.call_id)
);
return body;
}
function stripStoredItemReferences(body) {
@@ -92,7 +226,11 @@ function stripStoredItemReferences(body) {
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false;
if (item && typeof item === "object" && !Array.isArray(item)) {
if (item.type === "item_reference") return false;
if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id;
if (
typeof item.id === "string" &&
SERVER_ID_PATTERN.test(item.id) &&
!isNativeGrokCliItemId(item.id)
) delete item.id;
}
return true;
});
@@ -103,15 +241,23 @@ function stripStoredItemReferences(body) {
* Keep hosted tools (web_search / x_search) passthrough.
*/
function normalizeGrokCliTools(body) {
if (!Array.isArray(body.tools)) return;
if (!Array.isArray(body.tools) || body.tools.length === 0) {
delete body.tools;
delete body.tool_choice;
return;
}
const validNames = new Set();
const hostedTypes = new Set();
body.tools = body.tools.filter((tool) => {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
const type = typeof tool.type === "string" ? tool.type : "";
if (type !== "function") {
// Hosted tools: { type: "web_search" } / { type: "x_search" }
if (HOSTED_TOOL_TYPES.has(type)) return true;
if (HOSTED_TOOL_TYPES.has(type)) {
hostedTypes.add(type);
return true;
}
// Nested function shape without type
if (!type && tool.function) {
// fall through to function flatten below
@@ -143,8 +289,9 @@ function normalizeGrokCliTools(body) {
: typeof fn?.description === "string"
? fn.description
: "";
const parameters =
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
const parameters = type === "custom"
? GROK_CLI_FREEFORM_TOOL_PARAMETERS
: tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
? tool.parameters
: fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters)
? fn.parameters
@@ -155,14 +302,25 @@ function normalizeGrokCliTools(body) {
tool.name = name.slice(0, 128);
if (description) tool.description = description;
tool.parameters = parameters;
validNames.add(name);
validNames.add(tool.name);
return true;
});
if (body.tools.length === 0) {
delete body.tools;
delete body.tool_choice;
return;
}
if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) {
if (body.tool_choice.type === "function") {
const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : "";
if (!n || !validNames.has(n)) delete body.tool_choice;
const choiceType = typeof body.tool_choice.type === "string" ? body.tool_choice.type : "";
if (choiceType === "function" || choiceType === "custom") {
const rawName = body.tool_choice.name ?? body.tool_choice.function?.name;
const name = typeof rawName === "string" ? rawName.trim().slice(0, 128) : "";
if (!name || !validNames.has(name)) delete body.tool_choice;
else body.tool_choice = { type: "function", name };
} else if (!hostedTypes.has(choiceType)) {
delete body.tool_choice;
}
}
}
@@ -192,9 +350,9 @@ export class GrokCliExecutor extends BaseExecutor {
return this.config.baseUrl;
}
async refreshCredentials(credentials, log) {
async refreshCredentials(credentials, log, proxyOptions = null) {
if (!credentials?.refreshToken) return null;
return refreshProviderCredentials("grok-cli", credentials, log);
return refreshProviderCredentials("grok-cli", credentials, log, proxyOptions);
}
needsRefresh(credentials) {
@@ -210,13 +368,10 @@ export class GrokCliExecutor extends BaseExecutor {
if (v != null && headers[k] === undefined) headers[k] = v;
}
// Ensure token-auth marker is present even if headers map was overridden
headers["x-xai-token-auth"] = this.config.tokenAuth || "xai-grok-cli";
headers["x-grok-client-identifier"] =
this.config.clientIdentifier || headers["x-grok-client-identifier"] || "grok-pager";
this.config.clientIdentifier || headers["x-grok-client-identifier"] || GROK_CLI_CLIENT_IDENTIFIER;
headers["x-grok-client-version"] =
this.config.clientVersion || headers["x-grok-client-version"] || "0.2.93";
headers["x-authenticateresponse"] = "authenticate-response";
this.config.clientVersion || headers["x-grok-client-version"] || GROK_CLI_VERSION;
const sessionId = this._currentSessionId || credentials?.connectionId || crypto.randomUUID();
const reqId = this._currentReqId || crypto.randomUUID();
@@ -231,10 +386,6 @@ export class GrokCliExecutor extends BaseExecutor {
// Surface model override (CLI always sets this)
if (this._currentModel) headers["x-grok-model-override"] = this._currentModel;
if (this.config.compactionAt) {
headers["x-compaction-at"] = String(this.config.compactionAt);
}
// Identity: mapTokens stores email top-level AND in providerSpecificData;
// fall back either way so OAuth connections always fingerprint like the CLI.
const psd = credentials?.providerSpecificData || {};
@@ -267,13 +418,8 @@ export class GrokCliExecutor extends BaseExecutor {
transformRequest(model, body, stream, credentials) {
// Session / request ids for headers — stable per client conversation when possible
this._currentSessionId = resolveSessionId({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.connectionId || credentials?.id,
workspaceId: credentials?.providerSpecificData?.workspaceId,
scope: "grok-cli",
});
const requestKey = body;
this._currentSessionId = resolveGrokCliSessionId(credentials, body);
this._currentReqId = crypto.randomUUID();
this._agentId =
credentials?.providerSpecificData?.deviceId ||
@@ -302,11 +448,12 @@ export class GrokCliExecutor extends BaseExecutor {
// Keep role:"system" as-is — official grok-pager HAR sends system, not developer
// (Codex converts system→developer; Grok CLI does not).
normalizeGrokCliInput(body);
stripStoredItemReferences(body);
normalizeGrokCliTools(body);
// Turn index after input is finalized (user-message count, monotonic per session)
this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input);
this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input, requestKey);
body.stream = true;
body.store = false;
@@ -325,20 +472,28 @@ export class GrokCliExecutor extends BaseExecutor {
body.model = resolvedModel;
this._currentModel = resolvedModel;
// Reasoning effort priority: explicit > reasoning_effort > model suffix > default high
// Reasoning effort priority: explicit > reasoning_effort > model suffix > default high.
// grok-build and Composer reject reasoningEffort but still accept summary/encrypted continuity.
const supportsReasoningEffort = supportsGrokCliReasoningEffort(resolvedModel);
if (!body.reasoning || typeof body.reasoning !== "object") {
const effort = body.reasoning_effort || modelEffort || "high";
body.reasoning = { effort, summary: "concise" };
body.reasoning = { summary: "concise" };
if (supportsReasoningEffort) {
body.reasoning.effort = normalizeGrokCliEffort(body.reasoning_effort || modelEffort);
}
} else {
if (!body.reasoning.effort) {
body.reasoning.effort = body.reasoning_effort || modelEffort || "high";
if (supportsReasoningEffort) {
body.reasoning.effort = normalizeGrokCliEffort(
body.reasoning.effort || body.reasoning_effort || modelEffort,
);
} else {
delete body.reasoning.effort;
}
if (!body.reasoning.summary) body.reasoning.summary = "concise";
}
delete body.reasoning_effort;
// Encrypted reasoning for multi-turn continuity (CLI always requests this)
if (body.reasoning?.effort && body.reasoning.effort !== "none") {
if (body.reasoning && body.reasoning.effort !== "none") {
const include = Array.isArray(body.include) ? body.include : [];
if (!include.includes("reasoning.encrypted_content")) {
include.push("reasoning.encrypted_content");
+22 -23
View File
@@ -9,10 +9,11 @@ import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { getExecutor } from "../executors/index.js";
import { supportsGrokCliReasoningEffort } from "../config/grokCli.js";
import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js";
import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
@@ -21,8 +22,8 @@ import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.j
import { dedupeTools } from "../utils/toolDeduper.js";
import { injectCaveman } from "../rtk/caveman.js";
import { injectPonytail } from "../rtk/ponytail.js";
import { compressMessages } from "../rtk/index.js";
import { compressWithHeadroom, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js";
import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js";
import { compressWithPxpipe } from "../rtk/pxpipe.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
@@ -168,7 +169,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const msgN = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || body.messages?.length || body.input?.length || 0;
const toolN = translatedBody.tools?.length || body.tools?.length || 0;
const fmtStr = passthrough ? `FMT: ${sourceFormat} (passthrough)` : `FMT: ${sourceFormat}${targetFormat}`;
const think = log.fmtThink?.(extractThinking(translatedBody));
const showThinking = provider !== "grok-cli" || supportsGrokCliReasoningEffort(model);
const think = showThinking ? log.fmtThink?.(extractThinking(translatedBody)) : null;
const acc = credentials?.connectionName || credentials?.connectionId?.slice(0, 8) || "-";
const parts = [
`POST ${clientModel}${provider}/${model}`,
@@ -188,40 +190,37 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
delete translatedBody.tools;
}
// Token-saver summary parts, printed as one "⚙" line at the end (only active ones)
const xf = [];
// Per-request opt-out: client can bypass all token savers via header
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
// RTK: compress tool_result content
const rtkStats = compressMessages(translatedBody, rtkEnabled);
if (rtkStats?.hits?.length) {
const saved = rtkStats.bytesBefore - rtkStats.bytesAfter;
const pct = rtkStats.bytesBefore > 0 ? ((saved / rtkStats.bytesBefore) * 100).toFixed(0) : "0";
xf.push(`RTK ${saved}B(${pct}%)`);
}
const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled);
const rtkLine = formatRtkLog(rtkStats);
if (rtkLine) console.log(rtkLine);
// Headroom: optional external proxy compression; fail open if proxy is absent.
const headroomDiagnostics = {};
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
if (headroomStats) {
const before = headroomStats.tokens_before || 0;
const delta = headroomStats.tokens_saved || 0;
const pct = before > 0 ? ((delta / before) * 100).toFixed(1) : "0";
xf.push(`HEADROOM ${delta}tok(${pct}%)`);
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
const headroomLine = formatHeadroomLog(headroomStats);
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
if (headroomLine) {
log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`);
if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) {
log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`);
}
} else if (headroomEnabled) {
log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
}
} else if (tokenSaverEnabled && headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
// Token-saver flags accumulator for the single "⚙" log line below.
const xf = [];
// Caveman: inject terse-style system prompt
if (cavemanEnabled && cavemanLevel) {
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
injectCaveman(translatedBody, finalFormat, cavemanLevel);
xf.push(`CAVEMAN:${cavemanLevel}`);
}
// Ponytail: inject lazy-senior-dev system prompt
if (ponytailEnabled && ponytailLevel) {
if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) {
injectPonytail(translatedBody, finalFormat, ponytailLevel);
xf.push(`PONYTAIL:${ponytailLevel}`);
}
+166
View File
@@ -0,0 +1,166 @@
import { createErrorResult } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
import { PROVIDER_MEDIA } from "../providers/index.js";
// Upstream fetch deadline for video job submission/polling (the job itself is
// async upstream — this only bounds the HTTP round-trip, not video rendering).
const VIDEO_FETCH_TIMEOUT_MS = Number(process.env.VIDEO_FETCH_TIMEOUT_MS || 120000);
// POST /videos/* creates a billable upstream job. A network error after the
// request left the socket may still have created the job, so creation is NEVER
// auto-retried (the only re-send is the auth retry after a 401/403 refresh,
// which upstream rejects before job creation).
export const VIDEO_ACTIONS = new Set(["generations", "edits", "extensions"]);
export function getVideoConfig(provider) {
return PROVIDER_MEDIA[provider]?.videoConfig || null;
}
/** Strip bearer tokens / obvious secrets from text destined for clients or logs. */
export function sanitizeSecrets(text, credentials = null) {
if (!text) return text;
let out = String(text).replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
for (const key of ["accessToken", "refreshToken", "apiKey"]) {
const secret = credentials?.[key];
if (typeof secret === "string" && secret.length >= 8) {
out = out.split(secret).join("[redacted]");
}
}
return out;
}
function buildUpstreamUrl(config, action, requestId) {
const base = config.baseUrl.replace(/\/$/, "");
return requestId ? `${base}/${encodeURIComponent(requestId)}` : `${base}/${action}`;
}
function buildHeaders({ token, contentType, idempotencyKey }) {
const headers = { Accept: "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
if (contentType) headers["Content-Type"] = contentType;
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
return headers;
}
function combineSignals(signal, timeoutMs) {
const timeoutSignal = typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(timeoutMs) : null;
if (signal && timeoutSignal && typeof AbortSignal.any === "function") {
return AbortSignal.any([signal, timeoutSignal]);
}
return signal || timeoutSignal || undefined;
}
/**
* Transparent proxy for async video jobs (xAI Grok Imagine shape).
*
* - Forwards the raw body byte-for-byte (JSON or multipart) — no reshaping.
* - Passes upstream JSON (request_id, status, video.url, error) back verbatim.
* - 401/403 with a refresh token: refresh ONCE, retry ONCE. No other retry.
* - Upstream error text is sanitized before it reaches the client.
*
* @param {object} options
* @param {string} options.provider - Provider id (must have registry videoConfig)
* @param {"generations"|"edits"|"extensions"|null} options.action - Creation action (POST)
* @param {string|null} [options.requestId] - Poll target (GET /videos/{id})
* @param {Buffer|string|null} [options.rawBody] - Exact body to forward
* @param {string|null} [options.contentType] - Original Content-Type header
* @param {string|null} [options.idempotencyKey] - Forwarded Idempotency-Key
* @param {object} options.credentials - { accessToken?, apiKey?, refreshToken?, authType? }
* @param {AbortSignal} [options.signal] - Client cancellation signal
* @param {number} [options.timeoutMs]
* @param {object} [options.log]
* @param {function} [options.onCredentialsRefreshed]
* @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>}
*/
export async function handleVideoProxyCore({
provider,
action = null,
requestId = null,
rawBody = null,
contentType = null,
idempotencyKey = null,
credentials,
signal,
timeoutMs = VIDEO_FETCH_TIMEOUT_MS,
log,
onCredentialsRefreshed,
}) {
const config = getVideoConfig(provider);
if (!config) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support video generation`);
}
if (!requestId && !VIDEO_ACTIONS.has(action)) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`);
}
const method = requestId ? "GET" : "POST";
const url = buildUpstreamUrl(config, action, requestId);
const fetchSignal = combineSignals(signal, timeoutMs);
const doFetch = (token) =>
fetch(url, {
method,
headers: buildHeaders({ token, contentType: method === "POST" ? contentType : null, idempotencyKey: method === "POST" ? idempotencyKey : null }),
body: method === "POST" ? rawBody : undefined,
signal: fetchSignal,
});
let upstream;
try {
upstream = await doFetch(credentials?.accessToken || credentials?.apiKey);
} catch (error) {
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`);
}
// Never re-send a creation POST on network error — the job may already exist upstream.
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video upstream fetch failed: ${error.message}`, credentials));
}
// 401/403 → refresh once → retry once (OAuth accounts only; API keys can't refresh)
if (
(upstream.status === HTTP_STATUS.UNAUTHORIZED || upstream.status === HTTP_STATUS.FORBIDDEN) &&
credentials?.refreshToken
) {
let refreshed = null;
try {
refreshed = await refreshTokenByProvider(provider, credentials, log);
} catch (error) {
log?.warn?.("TOKEN", `${provider} | video refresh error: ${sanitizeSecrets(error.message, credentials)}`);
}
if (refreshed?.accessToken) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for video ${method}`);
Object.assign(credentials, refreshed);
if (onCredentialsRefreshed) await onCredentialsRefreshed(refreshed);
try {
await upstream.body?.cancel?.();
} catch { /* noop */ }
try {
upstream = await doFetch(credentials.accessToken || credentials.apiKey);
} catch (error) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials));
}
} else {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | video refresh failed — account needs re-auth`);
}
}
const bodyText = await upstream.text().catch(() => "");
if (!upstream.ok) {
const message = sanitizeSecrets(bodyText || `HTTP ${upstream.status}`, credentials);
return createErrorResult(upstream.status, `[${provider}] ${message.slice(0, 2000)}`);
}
// Success: pass the upstream JSON through untouched (request_id / status / video.url).
return {
success: true,
response: new Response(bodyText, {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("content-type") || "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
+24 -4
View File
@@ -98,6 +98,8 @@ export const MODEL_CAPABILITIES = {
"coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
};
const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 };
/**
* Provider-specific capability overrides. Keyed by provider alias/id.
*/
@@ -111,6 +113,20 @@ export const PROVIDER_CAPABILITIES = {
"deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
"deepseek-ai/deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
},
"kiro": {
"gpt-5.6-sol": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
},
// CodeBuddy.cn — authoritative per-model metadata from the gateway's model
// config (contextWindow=maxInputTokens, maxOutput=maxOutputTokens, vision=
// supportsImages). Every model reasons via OpenAI-style reasoning_effort
@@ -271,13 +287,17 @@ export const PATTERN_CAPABILITIES = [
export function getCapabilitiesForModel(provider, model) {
if (!model) return { ...DEFAULT_CAPABILITIES };
// Canonical exact lookup strips vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7".
const baseModel = model.includes("/") ? model.split("/").pop() : model;
// 1. Provider-specific override
if (provider && PROVIDER_CAPABILITIES[provider]?.[model]) {
return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] };
if (provider) {
const providerCaps = PROVIDER_CAPABILITIES[provider];
if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] };
if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] };
}
// 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7")
const baseModel = model.includes("/") ? model.split("/").pop() : model;
// 2. Canonical exact
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
+1 -1
View File
@@ -14,7 +14,7 @@ export default {
},
category: "apikey",
transport: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
headers: {},
quirks: { preserveCacheControl: true },
},
+1 -3
View File
@@ -1,5 +1,3 @@
import { CLAUDE_API_HEADERS } from "../shared.js";
export default {
id: "anthropic",
priority: 30,
@@ -19,7 +17,7 @@ export default {
baseUrl: "https://api.anthropic.com/v1/messages",
format: "claude",
headers: {
"Anthropic-Version": "2023-06-01",
"anthropic-version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
},
+9
View File
@@ -18,6 +18,7 @@ export default {
transport: {
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesUrl: "https://api.githubcopilot.com/responses",
messagesUrl: "https://api.githubcopilot.com/v1/messages",
headers: {
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.110.0",
@@ -46,6 +47,14 @@ export default {
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
// Note: routing to Copilot's Anthropic-native /v1/messages shim (see
// executors/github.js) is decided by model-NAME pattern at request time, not by
// a static targetFormat field here — Copilot's live model catalog (see
// services/copilotModels.js) regularly exposes claude-* models this static list
// hasn't caught up with yet (e.g. claude-opus-4.8), and a static per-entry
// targetFormat would silently miss those while also double-translating requests
// for models that ARE listed here (chatCore.js would pre-translate to Claude
// shape, then the executor would translate again). Keep these as plain entries.
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
+28 -18
View File
@@ -1,13 +1,21 @@
/**
* Grok CLI / Grok Build (cli-chat-proxy.grok.com)
*
* Source of truth: HAR capture of official grok-shell/grok-pager 0.2.93
* Source of truth: wire capture of official @xai-official/grok 0.2.99
* talking to https://cli-chat-proxy.grok.com (OpenAI Responses API).
*
* Distinct from:
* - `xai` → api.x.ai (API key / Grok Build OAuth PKCE)
* - `xai` → api.x.ai (API key / xAI API OAuth PKCE)
* - `grok-web` → grok.com web SSO cookie
*/
import {
GROK_CLI_BASE_URL,
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_MODEL,
GROK_CLI_USER_AGENT,
GROK_CLI_VERSION,
} from "../../config/grokCli.js";
export default {
id: "grok-cli",
priority: 275,
@@ -29,32 +37,28 @@ export default {
authModes: ["oauth"],
hasOAuth: true,
thinkingConfig: {
options: ["low", "medium", "high"],
options: ["low", "medium", "high", "xhigh"],
defaultMode: "high",
},
transport: {
baseUrl: "https://cli-chat-proxy.grok.com/v1/responses",
baseUrl: `${GROK_CLI_BASE_URL}/responses`,
format: "openai-responses",
forceStream: true,
modelsUrl: "https://cli-chat-proxy.grok.com/v1/models",
userUrl: "https://cli-chat-proxy.grok.com/v1/user",
billingUrl: "https://cli-chat-proxy.grok.com/v1/billing",
clientVersion: "0.2.93",
clientIdentifier: "grok-pager",
modelsUrl: `${GROK_CLI_BASE_URL}/models`,
userUrl: `${GROK_CLI_BASE_URL}/user`,
billingUrl: `${GROK_CLI_BASE_URL}/billing`,
clientVersion: GROK_CLI_VERSION,
clientIdentifier: GROK_CLI_CLIENT_IDENTIFIER,
tokenAuth: "xai-grok-cli",
headers: {
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
"x-authenticateresponse": "authenticate-response",
"User-Agent": GROK_CLI_USER_AGENT,
"x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER,
"x-grok-client-version": GROK_CLI_VERSION,
},
// Compaction threshold mirrored from CLI (x-compaction-at)
compactionAt: 400000,
// Quota tracker: official CLI polls billing?format=credits + user?include=subscription
usage: {
url: "https://cli-chat-proxy.grok.com/v1/billing?format=credits",
userUrl: "https://cli-chat-proxy.grok.com/v1/user?include=subscription",
url: `${GROK_CLI_BASE_URL}/billing?format=credits`,
userUrl: `${GROK_CLI_BASE_URL}/user?include=subscription`,
},
retry: {
429: { attempts: 2, delayMs: 2000 },
@@ -63,6 +67,12 @@ export default {
},
},
models: [
{
id: GROK_CLI_MODEL,
name: "Grok Build",
contextLength: 500000,
maxOutputTokens: 64000,
},
{ id: "grok-4.5", name: "Grok 4.5" },
{ id: "grok-4.5-high", name: "Grok 4.5 (High)", upstreamModelId: "grok-4.5" },
{ id: "grok-4.5-medium", name: "Grok 4.5 (Medium)", upstreamModelId: "grok-4.5" },
+12
View File
@@ -65,18 +65,30 @@ export default {
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image","audio"] },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" },
// Thinking variants
{ id: "claude-sonnet-5-thinking", name: "Claude Sonnet 5 (Thinking)" },
{ id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" },
{ id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" },
{ id: "gpt-5.6-sol-thinking", name: "GPT 5.6 Sol (Thinking)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" },
{ id: "gpt-5.6-terra-thinking", name: "GPT 5.6 Terra (Thinking)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" },
{ id: "gpt-5.6-luna-thinking", name: "GPT 5.6 Luna (Thinking)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" },
// Agentic variants
{ id: "claude-sonnet-5-agentic", name: "Claude Sonnet 5 (Agentic)" },
{ id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" },
{ id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" },
{ id: "gpt-5.6-sol-agentic", name: "GPT 5.6 Sol (Agentic)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" },
{ id: "gpt-5.6-terra-agentic", name: "GPT 5.6 Terra (Agentic)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" },
{ id: "gpt-5.6-luna-agentic", name: "GPT 5.6 Luna (Agentic)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" },
// Thinking + Agentic variants
{ id: "claude-sonnet-5-thinking-agentic", name: "Claude Sonnet 5 (Thinking + Agentic)" },
{ id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" },
{ id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" },
{ id: "gpt-5.6-sol-thinking-agentic", name: "GPT 5.6 Sol (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" },
{ id: "gpt-5.6-terra-thinking-agentic", name: "GPT 5.6 Terra (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" },
{ id: "gpt-5.6-luna-thinking-agentic", name: "GPT 5.6 Luna (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" },
],
oauth: {
ssoOidcEndpoint: "https://oidc.us-east-1.amazonaws.com",
+5 -1
View File
@@ -32,9 +32,13 @@ export default {
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
{ id: "grok-3", name: "Grok 3" },
{ id: "grok-2-image-1212", name: "Grok 2 Image", params: ["n","response_format"], kind: "image" },
{ id: "grok-imagine-video", name: "Grok Imagine Video", params: ["duration","aspect_ratio","resolution"], kind: "video" },
],
serviceKinds: ["llm","imageToText","webSearch","image"],
serviceKinds: ["llm","imageToText","webSearch","image","video"],
imageConfig: { baseUrl: "https://api.x.ai/v1/images/generations", bodyFields: ["model","prompt","n","response_format"] },
// Async video jobs (POST returns { request_id }, GET polls until done/failed).
// Docs: https://docs.x.ai/developers/rest-api-reference/inference/videos
videoConfig: { baseUrl: "https://api.x.ai/v1/videos" },
searchViaChat: {
defaultModel: "grok-4.20-reasoning",
endpoint: "https://api.x.ai/v1/responses",
+127
View File
@@ -0,0 +1,127 @@
import {
GROK_CLI_BASE_URL,
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_MODEL,
GROK_CLI_USER_AGENT,
GROK_CLI_VERSION,
} from "../config/grokCli.js";
import { refreshProviderCredentials } from "./oauthCredentialManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
const MODELS_URL = `${GROK_CLI_BASE_URL}/models`;
function modelEntries(data) {
const value = Array.isArray(data) ? data : data?.data ?? data?.models ?? data?.results ?? [];
if (Array.isArray(value)) return value.map((item) => [null, item]);
if (value && typeof value === "object") return Object.entries(value);
return [];
}
export function parseGrokCliModels(data) {
const seen = new Set();
const models = [];
for (const [key, raw] of modelEntries(data)) {
const item = typeof raw === "string" ? { id: raw } : raw;
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const id = String(
item.id ?? item.model_id ?? item.modelId ?? item.model ?? item.slug ?? key ?? item.name ?? "",
).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
const model = {
...item,
id,
name: item.display_name ?? item.displayName ?? item.name ?? id,
};
const contextLength = Number(
item.context_length ?? item.contextLength ?? item.context_window ?? item.contextWindow,
);
const maxOutputTokens = Number(item.max_output_tokens ?? item.maxOutputTokens);
if (Number.isFinite(contextLength) && contextLength > 0) model.contextLength = contextLength;
if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
model.maxOutputTokens = maxOutputTokens;
}
if (id === GROK_CLI_MODEL) {
model.contextLength ||= 500000;
model.maxOutputTokens ||= 64000;
}
models.push(model);
}
return models;
}
function buildHeaders(accessToken, providerSpecificData = {}) {
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": GROK_CLI_USER_AGENT,
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-version": GROK_CLI_VERSION,
"x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER,
"x-grok-client-mode": "headless",
};
const email = providerSpecificData?.email;
const userId = providerSpecificData?.userId || providerSpecificData?.principalId;
if (email) headers["x-email"] = email;
if (userId) headers["x-userid"] = userId;
return headers;
}
export async function resolveGrokCliModels(credentials, options = {}) {
const {
fetchFn = proxyAwareFetch,
log = console,
proxyOptions = null,
onCredentialsRefreshed,
} = options;
let accessToken = credentials?.accessToken;
if (!accessToken) return { models: [], warning: "Grok CLI access token is missing." };
const request = (token) => fetchFn(
MODELS_URL,
{
method: "GET",
headers: buildHeaders(token, credentials?.providerSpecificData),
},
proxyOptions,
);
try {
let response = await request(accessToken);
if ((response.status === 401 || response.status === 403) && credentials?.refreshToken) {
const refreshed = await refreshProviderCredentials(
"grok-cli",
credentials,
log,
proxyOptions,
);
if (refreshed?.accessToken) {
accessToken = refreshed.accessToken;
try {
await onCredentialsRefreshed?.(refreshed);
} catch (error) {
log?.warn?.("Grok CLI credential persistence failed", error);
}
response = await request(accessToken);
}
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
return {
models: [],
warning: `Grok CLI model discovery failed (${response.status})${detail ? `: ${detail.slice(0, 160)}` : ""}`,
};
}
const models = parseGrokCliModels(await response.json());
return models.length
? { models }
: { models: [], warning: "Grok CLI returned no selectable models." };
} catch (error) {
return { models: [], warning: `Grok CLI model discovery failed: ${error.message}` };
}
}
+7 -1
View File
@@ -17,6 +17,10 @@ for (const entry of REGISTRY) {
for (const a of entry.aliases || []) ALIAS_TO_PROVIDER_ID[a] = entry.id;
}
const BUILTIN_MODEL_ALIASES = {
"grok-build": "gcli/grok-build",
};
/**
* Resolve provider alias to provider ID
*/
@@ -104,7 +108,9 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
: aliasesOrGetter;
// Resolve alias
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
const resolved =
resolveModelAliasFromMap(parsed.model, aliases) ||
resolveModelAliasFromMap(parsed.model, BUILTIN_MODEL_ALIASES);
if (resolved) {
return resolved;
}
+61 -7
View File
@@ -24,6 +24,11 @@
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U, parseResetTime, toFiniteNumber } from "./shared.js";
import {
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_USER_AGENT,
GROK_CLI_VERSION,
} from "../../config/grokCli.js";
const USAGE = U("grok-cli");
const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
@@ -43,10 +48,11 @@ function buildGrokCliHeaders(accessToken, providerSpecificData = {}) {
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"User-Agent": GROK_CLI_USER_AGENT,
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
"x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER,
"x-grok-client-version": GROK_CLI_VERSION,
"x-grok-client-mode": "headless",
};
const email = psd.email;
const userId = psd.userId || psd.principalId;
@@ -55,8 +61,18 @@ function buildGrokCliHeaders(accessToken, providerSpecificData = {}) {
return headers;
}
function subscriptionTier(user, config) {
const rawTier =
user?.subscriptionTier ??
user?.subscription_tier ??
user?.subscription?.tier ??
config?.subscriptionTier ??
config?.subscription_tier;
return typeof rawTier === "string" ? rawTier.trim() : "";
}
function resolvePlan(user, config) {
const tier = typeof user?.subscriptionTier === "string" ? user.subscriptionTier.trim() : "";
const tier = subscriptionTier(user, config);
if (tier) {
return tier
.replace(/[_-]+/g, " ")
@@ -105,11 +121,42 @@ export function parseGrokCliBilling(billing, user = null) {
const periodEnd =
parseResetTime(config.billingPeriodEnd) ||
parseResetTime(config.billing_period_end) ||
parseResetTime(config.currentPeriod?.end) ||
parseResetTime(config.resetAt || config.resetsAt || config.periodEnd) ||
parseResetTime(root.billingPeriodEnd) ||
parseResetTime(root.billing_period_end) ||
parseResetTime(root.resetAt || root.resetsAt || root.periodEnd) ||
null;
const quotas = {};
const tier = subscriptionTier(user, config);
const subscriptionAccess = Boolean(tier) && !/^(free|none|null)$/i.test(tier);
// Current Grok Build responses expose included monthly usage at top level.
const monthlyLimit = unwrapVal(
config.monthlyLimit ?? config.monthly_limit ?? root.monthlyLimit ?? root.monthly_limit,
NaN,
);
const includedUsed = unwrapVal(
config.includedUsed ?? config.included_used ?? root.includedUsed ?? root.included_used,
NaN,
);
const totalUsed = unwrapVal(
config.totalUsed ?? config.total_used ?? root.totalUsed ?? root.total_used,
NaN,
);
if (Number.isFinite(monthlyLimit) && monthlyLimit > 0) {
quotas["Monthly included"] = makeQuota({
used: Number.isFinite(includedUsed)
? includedUsed
: Number.isFinite(totalUsed)
? totalUsed
: 0,
total: monthlyLimit,
resetAt: periodEnd,
});
}
// Primary: on-demand spending window (subscription / promo credits)
const onDemandCap = unwrapVal(config.onDemandCap ?? root.onDemandCap, NaN);
@@ -121,7 +168,12 @@ export function parseGrokCliBilling(billing, user = null) {
total: onDemandCap,
resetAt: periodEnd,
});
} else if (Number.isFinite(onDemandCap) && onDemandCap === 0 && Number.isFinite(onDemandUsed)) {
} else if (
!subscriptionAccess &&
Number.isFinite(onDemandCap) &&
onDemandCap === 0 &&
Number.isFinite(onDemandUsed)
) {
// Cap 0 is the exhausted free/promo state (chat returns 402 spending-limit).
// UI treats total===0 as unlimited, so use a synthetic 1/1 depleted row.
quotas["On-demand"] = {
@@ -199,6 +251,7 @@ export function parseGrokCliBilling(billing, user = null) {
quotas,
periodEnd,
exhausted,
subscriptionAccess,
rawConfig: config,
};
}
@@ -255,8 +308,9 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null,
if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) {
return {
plan: parsed.plan,
message:
"Grok Build connected, but no credit allotment was returned. Free promo may be exhausted — upgrade at https://grok.com/supergrok or add credits at https://grok.com/?_s=usage.",
message: parsed.subscriptionAccess
? "Subscription access is active; Grok does not expose a numeric included quota."
: "Grok Build connected, but no credit allotment was returned. Free promo may be exhausted.",
quotas: {},
};
}
+2 -2
View File
@@ -6,8 +6,8 @@ import { getCapabilitiesForModel } from "../../providers/capabilities.js";
// Each rule: optional provider, regex match on model, list of params to drop.
// A param is removed only when it is present (!== undefined).
const STRIP_RULES = [
// claude-opus-4 series: temperature is deprecated (Anthropic 400). #1748
{ match: /claude-opus-4/i, drop: ["temperature"] },
// All Claude models: temperature deprecated/rejected upstream (Anthropic 400). #1748
{ match: /claude/i, drop: ["temperature"] },
// GitHub Copilot gpt-5.4: temperature unsupported.
{ provider: "github", match: /gpt-5\.4/i, drop: ["temperature"] },
// GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713
@@ -229,6 +229,12 @@ function applyFormat(fmt, body, cfg, caps) {
}
case "claude-adaptive": {
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
// output_config.effort alone does NOT turn thinking on: Anthropic requires
// an explicit thinking:{type:"adaptive"} on Opus 4.6/4.7/4.8 and Sonnet 4.6
// ("thinking is off unless you explicitly set it"), and Anthropic-compatible
// shims (e.g. GitHub Copilot /v1/messages) default thinking off even for
// Sonnet 5. Send both fields — the documented adaptive-thinking shape.
body.thinking = { type: "adaptive" };
const level = toLevel(eff);
body.output_config = { effort: level === "xhigh" ? "high" : level };
break;
+10 -2
View File
@@ -103,8 +103,16 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
}
}
// Normalize thinking to the target provider-native format (config-driven, capability-aware)
applyThinking(targetFormat, model, result, provider, thinkingIntent);
// Normalize thinking to the target provider-native format (config-driven, capability-aware).
// Kiro's GenerateAssistantResponse request does not accept the generic top-level
// `thinking` field; its translators map thinking intent to KAS-compatible
// systemPrompt/additionalModelRequestFields instead.
const kiroThinkingMappedByTranslator =
targetFormat === FORMATS.KIRO &&
(sourceFormat === FORMATS.OPENAI || sourceFormat === FORMATS.CLAUDE);
if (!kiroThinkingMappedByTranslator) {
applyThinking(targetFormat, model, result, provider, thinkingIntent);
}
// Always normalize to clean OpenAI format when target is OpenAI
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
+65 -38
View File
@@ -24,13 +24,15 @@
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
import {
resolveKiroModel,
resolveKiroThinkingBudget,
buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT,
resolveDefaultProfileArn,
buildKiroAdditionalModelRequestFieldsForModel,
} from "../../config/kiroConstants.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
@@ -363,6 +365,18 @@ function reconcileOrphanedToolResults(history, currentMessage) {
}
}
function extractClaudeSystemText(system) {
if (!system) return "";
if (typeof system === "string") return system;
if (Array.isArray(system)) {
return system.map((s) => {
if (typeof s === "string") return s;
return s?.text || "";
}).filter(Boolean).join("\n");
}
return "";
}
/**
* Build a Kiro payload directly from a Claude Messages API request body.
*/
@@ -402,62 +416,75 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
? (credentials?.providerSpecificData?.profileArn || "")
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
let finalContent = currentMessage?.userInputMessage?.content || "";
// System prompt: pass via native systemInstruction field (Kiro/Q API supports it)
// and also prepend as <instructions> in user content as fallback for upstreams
// that don't support the native field.
let systemInstruction = undefined;
if (body.system) {
let systemText = "";
if (typeof body.system === "string") {
systemText = body.system;
} else if (Array.isArray(body.system)) {
systemText = body.system.map((s) => s.text || "").join("\n");
}
if (systemText) {
systemInstruction = systemText;
finalContent = `<instructions>\n${systemText}\n</instructions>\n\n${finalContent}`;
}
}
// Prefix order: thinking_mode tag, timestamp marker, then agentic prompt.
// Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a
// content fallback too because the CodeWhisperer surface does not always
// enforce top-level systemPrompt for direct calls.
const timestamp = new Date().toISOString();
const prefixParts = [];
if (thinkingBudget !== null) prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
prefixParts.push(`[Context: Current time is ${timestamp}]`);
if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
const systemPromptParts = [];
if (thinkingBudget !== null) systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
const systemInstruction = extractClaudeSystemText(body.system);
if (systemInstruction) systemPromptParts.push(systemInstruction);
const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n");
const currentTimeContext = `[Context: Current time is ${timestamp}]`;
const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n");
const sessionIdentity = resolveSessionIdentity({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.connectionId,
scope: "kiro",
});
const conversationId = sessionIdentity.sessionId;
const continuationId = resolveContinuationId({
sessionId: conversationId,
connectionId: credentials?.connectionId,
scope: "kiro",
ephemeral: sessionIdentity.ephemeral,
});
const replay = applyKiroSessionReplay({
conversationId,
connectionId: credentials?.connectionId,
modelId: upstreamModel,
systemPrompt,
contentPrefix,
currentContentPrefix: currentTimeContext,
history,
currentMessage,
});
const replayCurrent = replay.currentMessage?.userInputMessage || {};
const userInputMessage = {
content: finalContent,
content: replayCurrent.content || "",
modelId: upstreamModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext:
currentMessage.userInputMessage.userInputMessageContext,
...(replayCurrent.userInputMessageContext && {
userInputMessageContext: replayCurrent.userInputMessageContext,
}),
...(currentMessage?.userInputMessage?.images && {
images: currentMessage.userInputMessage.images,
...(replayCurrent.images && {
images: replayCurrent.images,
}),
};
if (systemInstruction) {
userInputMessage.systemInstruction = systemInstruction;
}
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
conversationId,
agentContinuationId: continuationId,
agentTaskType: "vibe",
currentMessage: {
userInputMessage,
},
history,
history: replay.history,
},
agentMode: "vibe",
};
if (profileArn) payload.profileArn = profileArn;
if (systemPrompt) payload.systemPrompt = systemPrompt;
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
if (additionalModelRequestFields) {
payload.additionalModelRequestFields = additionalModelRequestFields;
}
if (maxTokens || temperature !== undefined || topP !== undefined) {
payload.inferenceConfig = {};
@@ -201,6 +201,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
delete result.prompt_cache_key;
delete result.store;
delete result.reasoning;
delete result.client_metadata;
return result;
}
+49 -20
View File
@@ -5,13 +5,15 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
import { resolveSessionId } from "../../utils/sessionManager.js";
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
import {
resolveKiroModel,
resolveKiroThinkingBudget,
buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT,
resolveDefaultProfileArn
resolveDefaultProfileArn,
buildKiroAdditionalModelRequestFieldsForModel
} from "../../config/kiroConstants.js";
import { parseDataUri } from "../concerns/image.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
@@ -546,47 +548,74 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
? (credentials?.providerSpecificData?.profileArn || "")
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
let finalContent = currentMessage?.userInputMessage?.content || "";
const timestamp = new Date().toISOString();
// Build the system-prompt prefix that goes ABOVE the user message body.
// Order: thinking_mode tag first (so Kiro sees it before any user text),
// then context/timestamp marker, then optional agentic chunked-write prompt.
const prefixParts = [];
// Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback
// too because the CodeWhisperer surface does not always enforce top-level
// systemPrompt for direct calls.
const systemPromptParts = [];
if (thinkingBudget !== null) {
prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
}
prefixParts.push(`[Context: Current time is ${timestamp}]`);
if (agentic) {
prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
}
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n");
const currentTimeContext = `[Context: Current time is ${timestamp}]`;
const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n");
const sessionIdentity = resolveSessionIdentity({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" });
const conversationId = sessionIdentity.sessionId;
const continuationId = resolveContinuationId({
sessionId: conversationId,
connectionId: credentials?.connectionId,
scope: "kiro",
ephemeral: sessionIdentity.ephemeral,
});
const replay = applyKiroSessionReplay({
conversationId,
connectionId: credentials?.connectionId,
modelId: upstreamModel,
systemPrompt,
contentPrefix,
currentContentPrefix: currentTimeContext,
history,
currentMessage,
});
const replayCurrent = replay.currentMessage?.userInputMessage || {};
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }),
conversationId,
agentContinuationId: continuationId,
agentTaskType: "vibe",
currentMessage: {
userInputMessage: {
content: finalContent,
content: replayCurrent.content || "",
modelId: upstreamModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.images?.length > 0 && {
images: currentMessage.userInputMessage.images
...(replayCurrent.images?.length > 0 && {
images: replayCurrent.images
}),
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext
...(replayCurrent.userInputMessageContext && {
userInputMessageContext: replayCurrent.userInputMessageContext
})
}
},
history: history
}
history: replay.history
},
agentMode: "vibe",
};
if (profileArn) {
payload.profileArn = profileArn;
}
if (systemPrompt) payload.systemPrompt = systemPrompt;
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
if (additionalModelRequestFields) {
payload.additionalModelRequestFields = additionalModelRequestFields;
}
if (maxTokens || temperature !== undefined || topP !== undefined) {
payload.inferenceConfig = {};
+125
View File
@@ -0,0 +1,125 @@
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
const sessionStartStore = new Map();
const MAX_SESSION_STARTS = 5000;
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function sessionKey(connectionId, conversationId) {
return `${connectionId || ""}:${conversationId || ""}`;
}
function ensureUserMessageModelId(message, modelId) {
if (message?.userInputMessage && !message.userInputMessage.modelId && modelId) {
message.userInputMessage.modelId = modelId;
}
return message;
}
function ensureHistoryModelIds(history, modelId) {
for (const item of history || []) {
ensureUserMessageModelId(item, modelId);
}
return history;
}
function prefixUserMessage(message, contentPrefix, modelId) {
const out = clone(message) || { userInputMessage: { content: "" } };
if (!out.userInputMessage) out.userInputMessage = { content: "" };
ensureUserMessageModelId(out, modelId);
if (contentPrefix) {
const content = out.userInputMessage.content || "";
out.userInputMessage.content = content
? `${contentPrefix}\n\n${content}`
: contentPrefix;
}
return out;
}
function findFirstUserIndex(history) {
return history.findIndex((item) => item?.userInputMessage);
}
function rememberSessionStart(key, entry) {
if (sessionStartStore.size >= MAX_SESSION_STARTS) {
sessionStartStore.delete(sessionStartStore.keys().next().value);
}
sessionStartStore.set(key, { ...entry, lastUsed: Date.now() });
}
/**
* Preserve Kiro cacheability by freezing the first user message (`msg0`) for a
* session, replaying that exact message as the first history user on later
* turns, and injecting volatile current-time context only into the current turn.
*/
export function applyKiroSessionReplay({
conversationId,
connectionId,
modelId,
systemPrompt = "",
contentPrefix = "",
currentContentPrefix = "",
history = [],
currentMessage,
} = {}) {
const key = sessionKey(connectionId, conversationId);
const existing = conversationId ? sessionStartStore.get(key) : null;
const baseHistory = clone(history) || [];
const baseCurrent = clone(currentMessage) || { userInputMessage: { content: "" } };
if (existing && existing.modelId === modelId && existing.systemPrompt === systemPrompt) {
existing.lastUsed = Date.now();
const firstUserIndex = findFirstUserIndex(baseHistory);
const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId);
if (firstUserIndex >= 0) {
baseHistory[firstUserIndex] = sessionStart;
} else {
baseHistory.unshift(sessionStart);
}
return {
history: ensureHistoryModelIds(baseHistory, modelId),
currentMessage: prefixUserMessage(baseCurrent, currentContentPrefix, modelId),
replayed: true,
};
}
const firstUserIndex = findFirstUserIndex(baseHistory);
let sessionStart;
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
if (firstUserIndex >= 0) {
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
baseHistory[firstUserIndex] = clone(sessionStart);
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
} else {
sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId);
nextCurrent = clone(sessionStart);
}
if (conversationId) {
rememberSessionStart(key, {
sessionStart: clone(sessionStart),
modelId,
systemPrompt,
});
}
return {
history: ensureHistoryModelIds(baseHistory, modelId),
currentMessage: nextCurrent,
replayed: false,
};
}
export function clearKiroSessionReplayStore() {
sessionStartStore.clear();
}
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of sessionStartStore) {
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) sessionStartStore.delete(key);
}
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
if (cleanup.unref) cleanup.unref();
+49 -13
View File
@@ -13,6 +13,7 @@ import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
// Runtime storage: Key = connectionId, Value = { sessionId, lastUsed }
const runtimeSessionStore = new Map();
const continuationStore = new Map();
// Periodically evict entries that haven't been used within TTL
const cleanupInterval = setInterval(() => {
@@ -80,6 +81,7 @@ export function generateBinaryStyleId() {
export function clearSessionStore() {
runtimeSessionStore.clear();
assistantSessionStore.clear();
continuationStore.clear();
}
// Conversation-stable session store: Key = hash(scope+assistant text), Value = { sessionId, lastUsed }
@@ -87,9 +89,10 @@ const assistantSessionStore = new Map();
const ASSISTANT_MIN_LEN = 50;
const ASSISTANT_CAP_LEN = 50;
const MAX_ASSISTANT_SESSIONS = 5000;
const MAX_CONTINUATION_SESSIONS = 5000;
// Client headers/body fields that carry an upstream session id (priority order)
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id", "x-client-request-id"];
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"];
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
function sha16(text) {
@@ -131,7 +134,7 @@ function extractAntigravitySession(body) {
return m ? normalizeSessionId(m[1]) : null;
}
function extractClientSessionId(headers, body) {
function extractClientSessionId(headers, body, scope = "") {
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
if (claude) return `claude:${claude}`;
const antigravity = extractAntigravitySession(body);
@@ -140,18 +143,25 @@ function extractClientSessionId(headers, body) {
const v = headerValue(headers, key);
if (v) return v;
}
const requestId = scope === "kiro" ? null : headerValue(headers, "x-client-request-id");
if (requestId) return requestId;
const fromBody =
normalizeSessionId(body?.prompt_cache_key) ||
normalizeSessionId(body?.session_id) ||
normalizeSessionId(body?.conversation_id) ||
normalizeSessionId(body?.metadata?.user_id);
(scope === "kiro" ? null : normalizeSessionId(body?.metadata?.user_id));
return fromBody || null;
}
function requestMessages(body) {
if (Array.isArray(body?.messages)) return body.messages;
if (Array.isArray(body?.input)) return body.input;
return [];
}
// Accumulate assistant text from OpenAI/Responses-style input/messages (cap-limited)
function accumulateAssistantText(body) {
const items = Array.isArray(body?.input) ? body.input
: Array.isArray(body?.messages) ? body.messages : null;
const items = requestMessages(body);
if (!items) return "";
let text = "";
for (const item of items) {
@@ -193,16 +203,39 @@ function assistantTextSessionId(scope, body) {
* @param {string} [opts.connectionId] - Connection identifier (fallback scope)
* @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback)
* @param {string} [opts.scope] - Provider scope to isolate cache keys across providers
* @returns {string} A stable session id
* @returns {{sessionId: string, ephemeral: boolean}} A session id plus whether it is one-shot
*/
export function resolveSessionId({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
const client = extractClientSessionId(headers, body);
if (client) return client;
const fromAssistant = assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
if (fromAssistant) return fromAssistant;
export function resolveSessionIdentity({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
const client = extractClientSessionId(headers, body, scope);
if (client) return { sessionId: client, ephemeral: false };
const fromAssistant = scope === "kiro" ? null : assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
if (fromAssistant) return { sessionId: fromAssistant, ephemeral: false };
const ws = normalizeSessionId(workspaceId);
if (ws) return ws;
return deriveSessionId(connectionId);
if (ws) return { sessionId: ws, ephemeral: false };
if (scope === "kiro") return { sessionId: generateBinaryStyleId(), ephemeral: true };
return { sessionId: deriveSessionId(connectionId), ephemeral: false };
}
export function resolveSessionId(opts = {}) {
return resolveSessionIdentity(opts).sessionId;
}
export function resolveContinuationId({ sessionId, connectionId, scope = "", ephemeral = false } = {}) {
if (ephemeral) return crypto.randomUUID();
const key = `${scope}:${connectionId || ""}:${sessionId || ""}`;
const existing = continuationStore.get(key);
if (existing) {
existing.lastUsed = Date.now();
continuationStore.delete(key);
continuationStore.set(key, existing);
return existing.continuationId;
}
const continuationId = crypto.randomUUID();
if (continuationStore.size >= MAX_CONTINUATION_SESSIONS) {
continuationStore.delete(continuationStore.keys().next().value);
}
continuationStore.set(key, { continuationId, lastUsed: Date.now() });
return continuationId;
}
// Capture session id from request body + credentials (envelope still intact here)
@@ -227,5 +260,8 @@ const assistantCleanup = setInterval(() => {
for (const [key, entry] of assistantSessionStore) {
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) assistantSessionStore.delete(key);
}
for (const [key, entry] of continuationStore) {
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) continuationStore.delete(key);
}
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
if (assistantCleanup.unref) assistantCleanup.unref();