diff --git a/.env.example b/.env.example index 88ba7cd..95db3eb 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ GROK_API_KEY=your_grok_api_key_here # Optional: Custom API base URL (default: https://api.x.ai/v1) # GROK_BASE_URL=https://api.x.ai/v1 -# Optional: Default model (default: grok-4-1-fast) -# GROK_MODEL=grok-4-1-fast +# Optional: Default model (default: grok-4-1-fast-reasoning) +# GROK_MODEL=grok-4-1-fast-reasoning # Optional: Max tokens per response (default: 16384) # GROK_MAX_TOKENS=16384 diff --git a/README.md b/README.md index 2a4302e..97abb71 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ grok fix the flaky test in src/foo.test.ts | Thing | What it means | |--------|----------------| -| **Grok-native** | Defaults tuned for Grok; models like **`grok-code-fast-1`**, **`grok-4-1-fast`**, flagship and fast variants—run `grok models` for the full menu. | +| **Grok-native** | Defaults tuned for Grok; models like **`grok-code-fast-1`**, **`grok-4-1-fast-reasoning`**, **`grok-4.20-multi-agent-0309`**, plus flagship and fast variants—run `grok models` for the full menu. | | **X + web search** | **`search_x`** and **`search_web`** tools—live posts and docs without pretending the internet stopped in 2023. | | **Sub-agents (default behavior)** | Foreground **`task`** delegation (e.g. explore vs general) plus background **`delegate`** for read-only deep dives—parallelize like you mean it. | | **Custom sub-agents** | Define named agents with **`subAgents`** in **`~/.grok/user-settings.json`** and manage them from the TUI with **`/agents`**. | diff --git a/src/agent/agent.ts b/src/agent/agent.ts index d15c867..124a859 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -1,6 +1,6 @@ import { type ModelMessage, stepCountIs, streamText, type ToolSet } from "ai"; -import { createProvider, generateTitle as genTitle, type XaiProvider } from "../grok/client"; -import { getModelInfo } from "../grok/models"; +import { createProvider, generateTitle as genTitle, resolveModelRuntime, type XaiProvider } from "../grok/client"; +import { DEFAULT_MODEL, getModelInfo, normalizeModelId } from "../grok/models"; import { createTools } from "../grok/tools"; import { buildMcpToolSet } from "../mcp/runtime"; import { @@ -45,6 +45,7 @@ import { shouldCompactContext, } from "./compaction"; import { DelegationManager } from "./delegations"; +import { containsEncryptedReasoning, sanitizeModelMessages } from "./reasoning"; const MAX_TOOL_ROUNDS = 400; @@ -287,6 +288,22 @@ function buildSubagentPrompt( ].join("\n"); } +function applyModelConstraints(system: string, modelId: string): string { + const modelInfo = getModelInfo(modelId); + if (modelInfo?.supportsClientTools !== false) { + return system; + } + + return [ + system, + "", + "MODEL CONSTRAINTS:", + "- The selected model does not support client-side CLI tool calls in this environment.", + "- Do not call bash, read_file, write_file, edit_file, task, delegate, delegation, or MCP tools.", + "- Answer directly using only the conversation context already provided.", + ].join("\n"); +} + export class Agent { private provider: XaiProvider | null = null; private apiKey: string | null = null; @@ -319,7 +336,7 @@ export class Agent { } this.bash = new BashTool(); this.delegations = new DelegationManager(() => this.bash.getCwd()); - this.modelId = model || "grok-4-1-fast"; + this.modelId = normalizeModelId(model || DEFAULT_MODEL); this.maxToolRounds = maxToolRounds || MAX_TOOL_ROUNDS; const envMax = Number(process.env.GROK_MAX_TOKENS); this.maxTokens = Number.isFinite(envMax) && envMax > 0 ? envMax : 16_384; @@ -341,9 +358,9 @@ export class Agent { } setModel(model: string): void { - this.modelId = model; + this.modelId = normalizeModelId(model); if (this.sessionStore && this.session) { - this.sessionStore.setModel(this.session.id, model); + this.sessionStore.setModel(this.session.id, this.modelId); this.session = this.sessionStore.getRequiredSession(this.session.id); } } @@ -410,7 +427,7 @@ export class Agent { } const generated = await genTitle(provider, userMessage); - this.recordUsage(generated.usage, "title", "grok-3-mini-fast"); + this.recordUsage(generated.usage, "title", generated.modelId); if (this.sessionStore && this.session && !this.session.title && generated.title) { this.sessionStore.setTitle(this.session.id, generated.title); this.session = this.sessionStore.getRequiredSession(this.session.id); @@ -553,12 +570,17 @@ export class Agent { let lastActivity = initialDetail; let childTools: ToolSet = childBaseTools; let closeMcp: (() => Promise) | undefined; - const childModelId = isExplore ? "grok-4-1-fast" : custom ? custom.model : this.modelId; + const childModelId = normalizeModelId(isExplore ? DEFAULT_MODEL : custom ? custom.model : this.modelId); + const childRuntime = resolveModelRuntime(provider, childModelId); + const childSystem = applyModelConstraints( + buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, subagents), + childRuntime.modelId, + ); onActivity?.(initialDetail); try { - if (childMode === "agent") { + if (childMode === "agent" && childRuntime.modelInfo?.supportsClientTools !== false) { const mcpBundle = await buildMcpToolSet(loadMcpServers()); closeMcp = mcpBundle.close; childTools = { ...childBaseTools, ...mcpBundle.tools }; @@ -569,17 +591,20 @@ export class Agent { } const result = streamText({ - model: provider(childModelId), - system: buildSubagentPrompt(request, childBash.getCwd(), custom ?? null, subagents), + model: childRuntime.model, + system: childSystem, messages: [{ role: "user", content: request.prompt }], - tools: childTools, + tools: childRuntime.modelInfo?.supportsClientTools === false ? {} : childTools, stopWhen: stepCountIs(Math.min(this.maxToolRounds, isExplore ? 60 : 120)), maxRetries: 0, abortSignal: signal, temperature: isExplore ? 0.2 : 0.5, - maxOutputTokens: Math.min(this.maxTokens, 8_192), + ...(childRuntime.modelInfo?.supportsMaxOutputTokens === false + ? {} + : { maxOutputTokens: Math.min(this.maxTokens, 8_192) }), + ...(childRuntime.providerOptions ? { providerOptions: childRuntime.providerOptions } : {}), onFinish: ({ totalUsage }) => { - this.recordUsage(totalUsage, "task", childModelId); + this.recordUsage(totalUsage, "task", childRuntime.modelId); }, }); @@ -789,14 +814,20 @@ export class Agent { const provider = this.requireProvider(); const subagents = loadValidSubAgents(); - const system = buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext, subagents); - const modelInfo = getModelInfo(this.modelId); + const system = applyModelConstraints( + buildSystemPrompt(this.bash.getCwd(), this.mode, this.planContext, subagents), + this.modelId, + ); + const runtime = resolveModelRuntime(provider, this.modelId); + const modelInfo = runtime.modelInfo; this.planContext = null; let attemptedOverflowRecovery = false; try { while (true) { let assistantText = ""; + let reasoningPreview = ""; + let encryptedReasoningHidden = false; let streamOk = false; let closeMcp: (() => Promise) | undefined; let stepNumber = -1; @@ -824,8 +855,8 @@ export class Agent { listDelegations: () => this.listDelegations(), subagents, }); - let tools: ToolSet = baseTools; - if (this.mode === "agent") { + let tools: ToolSet = runtime.modelInfo?.supportsClientTools === false ? {} : baseTools; + if (this.mode === "agent" && runtime.modelInfo?.supportsClientTools !== false) { const mcpBundle = await buildMcpToolSet(loadMcpServers()); closeMcp = mcpBundle.close; tools = { ...baseTools, ...mcpBundle.tools }; @@ -835,7 +866,7 @@ export class Agent { } const result = streamText({ - model: provider(this.modelId), + model: runtime.model, system, messages: this.messages, tools, @@ -843,7 +874,8 @@ export class Agent { maxRetries: 0, abortSignal: signal, temperature: 0.7, - maxOutputTokens: this.maxTokens, + ...(runtime.modelInfo?.supportsMaxOutputTokens === false ? {} : { maxOutputTokens: this.maxTokens }), + ...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}), experimental_onStepStart: (event: unknown) => { stepNumber = getStepNumber(event, stepNumber + 1); notifyObserver(observer?.onStepStart, { @@ -862,7 +894,7 @@ export class Agent { }); }, onFinish: ({ totalUsage }) => { - this.recordUsage(totalUsage, "message"); + this.recordUsage(totalUsage, "message", runtime.modelId); }, }); @@ -879,6 +911,14 @@ export class Agent { break; case "reasoning-delta": + reasoningPreview = `${reasoningPreview}${part.text}`.slice(-256); + if (containsEncryptedReasoning(reasoningPreview)) { + if (!encryptedReasoningHidden) { + encryptedReasoningHidden = true; + yield { type: "reasoning", content: "[Encrypted reasoning hidden]" }; + } + break; + } yield { type: "reasoning", content: part.text }; break; @@ -933,7 +973,7 @@ export class Agent { try { const response = await result.response; if (!signal.aborted) { - this.appendCompletedTurn(userModelMessage, response.messages); + this.appendCompletedTurn(userModelMessage, sanitizeModelMessages(response.messages)); streamOk = true; } } catch (responseError: unknown) { diff --git a/src/agent/compaction.ts b/src/agent/compaction.ts index 4f6f7f1..a393070 100644 --- a/src/agent/compaction.ts +++ b/src/agent/compaction.ts @@ -1,5 +1,6 @@ import { generateText, type ModelMessage } from "ai"; -import type { XaiProvider } from "../grok/client"; +import { resolveModelRuntime, type XaiProvider } from "../grok/client"; +import { containsEncryptedReasoning } from "./reasoning"; export interface CompactionSettings { reserveTokens: number; @@ -110,11 +111,15 @@ function getTextParts(content: unknown): string[] { continue; } if (part.type === "reasoning" && typeof part.text === "string") { - parts.push(part.text); + if (!containsEncryptedReasoning(part.text)) { + parts.push(part.text); + } continue; } if (part.type === "reasoning" && typeof part.reasoning === "string") { - parts.push(part.reasoning); + if (!containsEncryptedReasoning(part.reasoning)) { + parts.push(part.reasoning); + } } } return parts; @@ -409,14 +414,18 @@ async function summarizeConversation( promptParts.push(`Additional focus: ${customInstructions.trim()}`); } + const runtime = resolveModelRuntime(provider, modelId); const { text } = await generateText({ - model: provider(modelId), + model: runtime.model, system: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptParts.filter(Boolean).join("\n\n"), abortSignal: signal, maxRetries: 0, temperature: 0.2, - maxOutputTokens: Math.max(512, Math.floor(reserveTokens * 0.8)), + ...(runtime.modelInfo?.supportsMaxOutputTokens === false + ? {} + : { maxOutputTokens: Math.max(512, Math.floor(reserveTokens * 0.8)) }), + ...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}), }); return text.trim(); diff --git a/src/agent/reasoning.test.ts b/src/agent/reasoning.test.ts new file mode 100644 index 0000000..d7c1458 --- /dev/null +++ b/src/agent/reasoning.test.ts @@ -0,0 +1,29 @@ +import type { ModelMessage } from "ai"; +import { describe, expect, it } from "vitest"; +import { containsEncryptedReasoning, sanitizeModelMessages } from "./reasoning"; + +describe("reasoning helpers", () => { + it("detects encrypted reasoning markers", () => { + expect(containsEncryptedReasoning("-----BEGIN PGP MESSAGE-----\nabc")).toBe(true); + expect(containsEncryptedReasoning("normal reasoning text")).toBe(false); + }); + + it("removes encrypted reasoning from assistant messages", () => { + const messages = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "-----BEGIN PGP MESSAGE-----\nabc" }, + { type: "text", text: "Final answer" }, + ], + }, + ] as ModelMessage[]; + + expect(sanitizeModelMessages(messages)).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "Final answer" }], + }, + ]); + }); +}); diff --git a/src/agent/reasoning.ts b/src/agent/reasoning.ts new file mode 100644 index 0000000..bc4d434 --- /dev/null +++ b/src/agent/reasoning.ts @@ -0,0 +1,64 @@ +import type { ModelMessage } from "ai"; + +const ENCRYPTED_REASONING_MARKERS = [ + "-----BEGIN PGP MESSAGE-----", + "-----BEGIN PGP ARMORED FILE-----", + "-----BEGIN AGE ENCRYPTED FILE-----", + "encrypted_content", +] as const; +type AssistantMessage = Extract; +type AssistantContent = AssistantMessage["content"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function getReasoningText(part: unknown): string | null { + if (!isRecord(part) || part.type !== "reasoning") return null; + if (typeof part.text === "string") return part.text; + if (typeof part.reasoning === "string") return part.reasoning; + return null; +} + +export function containsEncryptedReasoning(text: string): boolean { + const lower = text.toLowerCase(); + return ENCRYPTED_REASONING_MARKERS.some((marker) => lower.includes(marker.toLowerCase())); +} + +function sanitizeAssistantContent(content: AssistantContent): AssistantContent { + if (!Array.isArray(content)) return content; + + const filtered = content.filter((part) => { + const reasoningText = getReasoningText(part); + return !reasoningText || !containsEncryptedReasoning(reasoningText); + }); + + return filtered.length === content.length ? content : (filtered as typeof content); +} + +export function sanitizeModelMessages(messages: ModelMessage[]): ModelMessage[] { + let changed = false; + const sanitized: ModelMessage[] = []; + + for (const message of messages) { + if (message.role !== "assistant") { + sanitized.push(message); + continue; + } + + const content = sanitizeAssistantContent(message.content); + if (content !== message.content) { + changed = true; + } + + if (Array.isArray(content) && content.length === 0) { + changed = true; + continue; + } + + const nextMessage: ModelMessage = content === message.content ? message : { ...message, content }; + sanitized.push(nextMessage); + } + + return changed ? sanitized : messages; +} diff --git a/src/grok/client.ts b/src/grok/client.ts index 61b5c03..abbbcfe 100644 --- a/src/grok/client.ts +++ b/src/grok/client.ts @@ -1,9 +1,19 @@ import { createXai } from "@ai-sdk/xai"; import { generateText } from "ai"; +import type { ModelInfo, ReasoningEffort } from "../types/index"; +import { getReasoningEffortForModel } from "../utils/settings"; +import { getEffectiveReasoningEffort, getModelInfo, normalizeModelId } from "./models"; export type XaiProvider = ReturnType; +export type XaiChatModel = ReturnType; +export type XaiResponsesModel = ReturnType; +export type GrokRuntimeModel = XaiChatModel | XaiResponsesModel; + +const DEFAULT_TITLE_MODEL = "grok-4-1-fast-non-reasoning"; + export interface GeneratedTitle { title: string; + modelId: string; usage?: { totalTokens?: number; inputTokens?: number; @@ -11,6 +21,17 @@ export interface GeneratedTitle { }; } +export interface ResolvedModelRuntime { + model: GrokRuntimeModel; + modelId: string; + modelInfo?: ModelInfo; + providerOptions?: { + xai: { + reasoningEffort: ReasoningEffort; + }; + }; +} + export function createProvider(apiKey: string, baseURL?: string): XaiProvider { return createXai({ apiKey, @@ -18,12 +39,33 @@ export function createProvider(apiKey: string, baseURL?: string): XaiProvider { }); } +export function resolveModelRuntime(provider: XaiProvider, requestedModelId: string): ResolvedModelRuntime { + const modelId = normalizeModelId(requestedModelId); + const modelInfo = getModelInfo(modelId); + const reasoningEffort = getEffectiveReasoningEffort(modelId, getReasoningEffortForModel(modelId)); + + return { + model: modelInfo?.responsesOnly ? provider.responses(modelId) : provider(modelId), + modelId, + modelInfo, + providerOptions: reasoningEffort + ? { + xai: { + reasoningEffort, + }, + } + : undefined, + }; +} + export async function generateTitle(provider: XaiProvider, userMessage: string): Promise { + const runtime = resolveModelRuntime(provider, DEFAULT_TITLE_MODEL); try { const { text, usage } = await generateText({ - model: provider("grok-3-mini-fast"), + model: runtime.model, temperature: 0.5, - maxOutputTokens: 60, + ...(runtime.modelInfo?.supportsMaxOutputTokens === false ? {} : { maxOutputTokens: 60 }), + ...(runtime.providerOptions ? { providerOptions: runtime.providerOptions } : {}), system: [ "You are a title generator. Output ONLY a short title. Nothing else.", "Rules:", @@ -39,9 +81,10 @@ export async function generateTitle(provider: XaiProvider, userMessage: string): }); return { title: text?.trim().replace(/^["']|["']$/g, "") || "New session", + modelId: runtime.modelId, usage, }; } catch { - return { title: "New session" }; + return { title: "New session", modelId: runtime.modelId }; } } diff --git a/src/grok/models.test.ts b/src/grok/models.test.ts new file mode 100644 index 0000000..33e355c --- /dev/null +++ b/src/grok/models.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MODEL, + getEffectiveReasoningEffort, + getModelInfo, + getSupportedReasoningEfforts, + normalizeModelId, +} from "./models"; + +describe("models", () => { + it("keeps the default model on a canonical reasoning id", () => { + expect(DEFAULT_MODEL).toBe("grok-4-1-fast-reasoning"); + }); + + it("normalizes current aliases to canonical ids", () => { + expect(normalizeModelId("grok-4-1-fast")).toBe("grok-4-1-fast-reasoning"); + expect(normalizeModelId("grok-4.20-multi-agent")).toBe("grok-4.20-multi-agent-0309"); + expect(normalizeModelId("x-ai/grok-4.20-multi-agent-beta")).toBe("grok-4.20-multi-agent-0309"); + expect(normalizeModelId("xai/grok-code-fast-1")).toBe("grok-code-fast-1"); + }); + + it("returns model metadata for aliased ids", () => { + expect(getModelInfo("grok-4-1-fast")?.id).toBe("grok-4-1-fast-reasoning"); + expect(getModelInfo("grok-4.20-multi-agent")?.responsesOnly).toBe(true); + expect(getModelInfo("grok-4.20-multi-agent")?.supportsClientTools).toBe(false); + }); + + it("reports supported reasoning-effort levels", () => { + expect(getSupportedReasoningEfforts("grok-4.20-multi-agent-0309")).toEqual(["low", "medium", "high", "xhigh"]); + expect(getSupportedReasoningEfforts("grok-4-1-fast-reasoning")).toEqual(["low", "high"]); + expect(getSupportedReasoningEfforts("grok-4-1-fast-non-reasoning")).toEqual([]); + }); + + it("resolves effective reasoning effort with defaults and overrides", () => { + expect(getEffectiveReasoningEffort("grok-4.20-multi-agent-0309")).toBe("low"); + expect(getEffectiveReasoningEffort("grok-4.20-multi-agent-0309", "high")).toBe("high"); + expect(getEffectiveReasoningEffort("grok-4-1-fast-reasoning")).toBeUndefined(); + }); +}); diff --git a/src/grok/models.ts b/src/grok/models.ts index d7e043a..9a77e98 100644 --- a/src/grok/models.ts +++ b/src/grok/models.ts @@ -1,6 +1,78 @@ -import type { ModelInfo } from "../types/index"; +import type { ModelInfo, ReasoningEffort } from "../types/index"; export const MODELS: ModelInfo[] = [ + { + id: "grok-4.20-multi-agent-0309", + name: "Grok 4.20 Multi-Agent", + contextWindow: 2_000_000, + inputPrice: 2.0, + outputPrice: 6.0, + reasoning: true, + description: "Realtime multi-agent research model", + aliases: ["grok-4.20-multi-agent", "grok-4.20-multi-agent-beta"], + responsesOnly: true, + multiAgent: true, + supportsClientTools: false, + supportsMaxOutputTokens: false, + defaultReasoningEffort: "low", + }, + { + id: "grok-4.20-0309-reasoning", + name: "Grok 4.20 Reasoning", + contextWindow: 2_000_000, + inputPrice: 2.0, + outputPrice: 6.0, + reasoning: true, + description: "Grok 4.20 reasoning release", + aliases: ["grok-4.20-beta-0309", "grok-4.20-beta", "grok-beta"], + }, + { + id: "grok-4.20-0309-non-reasoning", + name: "Grok 4.20 Non-Reasoning", + contextWindow: 2_000_000, + inputPrice: 2.0, + outputPrice: 6.0, + reasoning: false, + description: "Grok 4.20 non-reasoning release", + }, + { + id: "grok-4-1-fast-reasoning", + name: "Grok 4.1 Fast Reasoning", + contextWindow: 2_000_000, + inputPrice: 0.2, + outputPrice: 0.5, + reasoning: true, + description: "Fast reasoning model with 2M context", + aliases: ["grok-4-1-fast"], + }, + { + id: "grok-4-1-fast-non-reasoning", + name: "Grok 4.1 Fast Non-Reasoning", + contextWindow: 2_000_000, + inputPrice: 0.2, + outputPrice: 0.5, + reasoning: false, + description: "Fast non-reasoning model with 2M context", + }, + { + id: "grok-4-fast-reasoning", + name: "Grok 4 Fast Reasoning", + contextWindow: 2_000_000, + inputPrice: 0.2, + outputPrice: 0.5, + reasoning: true, + description: "Legacy fast reasoning model", + aliases: ["grok-4-fast"], + }, + { + id: "grok-4-fast-non-reasoning", + name: "Grok 4 Fast Non-Reasoning", + contextWindow: 2_000_000, + inputPrice: 0.2, + outputPrice: 0.5, + reasoning: false, + description: "Legacy fast non-reasoning model", + }, { id: "grok-4-0709", name: "Grok 4", @@ -10,46 +82,20 @@ export const MODELS: ModelInfo[] = [ reasoning: true, description: "Flagship reasoning model", }, - { - id: "grok-4.20-beta-0309", - name: "Grok 4.20 Beta", - contextWindow: 2_000_000, - inputPrice: 2.0, - outputPrice: 6.0, - reasoning: true, - description: "Multi-agent reasoning beta with 2M context", - }, - { - id: "grok-4-fast", - name: "Grok 4 Fast", - contextWindow: 2_000_000, - inputPrice: 0.2, - outputPrice: 0.5, - reasoning: true, - description: "Fast reasoning with 2M context", - }, - { - id: "grok-4-1-fast", - name: "Grok 4.1 Fast", - contextWindow: 2_000_000, - inputPrice: 0.2, - outputPrice: 0.5, - reasoning: true, - description: "Latest fast model with 2M context", - }, { id: "grok-code-fast-1", - name: "Grok Code Fast", + name: "Grok Code Fast 1", contextWindow: 256_000, inputPrice: 0.2, outputPrice: 1.5, - reasoning: false, - description: "Optimized for code generation", + reasoning: true, + description: "Fast coding model with reasoning", + aliases: ["grok-code-fast"], }, { id: "grok-3", name: "Grok 3", - contextWindow: 131_000, + contextWindow: 131_072, inputPrice: 3.0, outputPrice: 15.0, reasoning: false, @@ -58,20 +104,61 @@ export const MODELS: ModelInfo[] = [ { id: "grok-3-mini", name: "Grok 3 Mini", - contextWindow: 131_000, + contextWindow: 131_072, inputPrice: 0.3, outputPrice: 0.5, reasoning: false, description: "Budget-friendly compact model", + aliases: ["grok-3-mini-fast"], }, ]; -export const DEFAULT_MODEL = "grok-4-1-fast"; +const PROVIDER_PREFIX_RE = /^(x-ai|xai)\//i; +const aliasMap = new Map(); + +for (const model of MODELS) { + aliasMap.set(model.id.toLowerCase(), model.id); + for (const alias of model.aliases ?? []) { + aliasMap.set(alias.toLowerCase(), model.id); + } +} + +export const DEFAULT_MODEL = + MODELS.find((model) => model.id === "grok-4-1-fast-reasoning")?.id ?? MODELS[0]?.id ?? "grok-4-1-fast-reasoning"; + +export function normalizeModelId(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed) return trimmed; + + const withoutProviderPrefix = trimmed.replace(PROVIDER_PREFIX_RE, ""); + return aliasMap.get(withoutProviderPrefix.toLowerCase()) ?? withoutProviderPrefix; +} export function getModelInfo(modelId: string): ModelInfo | undefined { - return MODELS.find((m) => m.id === modelId); + const normalized = normalizeModelId(modelId); + return MODELS.find((m) => m.id === normalized); } export function getModelIds(): string[] { return MODELS.map((m) => m.id); } + +export function isKnownModelId(modelId: string): boolean { + return !!getModelInfo(modelId); +} + +export function getSupportedReasoningEfforts(modelId: string): ReasoningEffort[] { + const modelInfo = getModelInfo(modelId); + if (!modelInfo?.reasoning) return []; + if (modelInfo.multiAgent) return ["low", "medium", "high", "xhigh"]; + if (modelInfo.responsesOnly) return ["low", "medium", "high"]; + return ["low", "high"]; +} + +export function getEffectiveReasoningEffort(modelId: string, override?: ReasoningEffort): ReasoningEffort | undefined { + const supported = getSupportedReasoningEfforts(modelId); + if (supported.length === 0) return undefined; + if (override && supported.includes(override)) return override; + const defaultEffort = getModelInfo(modelId)?.defaultReasoningEffort; + return defaultEffort && supported.includes(defaultEffort) ? defaultEffort : undefined; +} diff --git a/src/grok/tools.ts b/src/grok/tools.ts index 4b447ff..948a82a 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -6,7 +6,7 @@ import type { AgentMode, TaskRequest, ToolResult } from "../types/index"; import { type CustomSubagentConfig, loadValidSubAgents } from "../utils/settings"; import type { XaiProvider } from "./client"; -const RESPONSES_SEARCH_MODEL = "grok-4-fast-non-reasoning"; +const RESPONSES_SEARCH_MODEL = "grok-4-1-fast-non-reasoning"; interface CreateToolsOptions { runTask?: (request: TaskRequest, abortSignal?: AbortSignal) => Promise; diff --git a/src/index.ts b/src/index.ts index 4aa6222..d9fe4a6 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import * as dotenv from "dotenv"; import packageJson from "../package.json"; import { Agent } from "./agent/agent"; import { completeDelegation, failDelegation, loadDelegation } from "./agent/delegations"; -import { MODELS } from "./grok/models"; +import { MODELS, normalizeModelId } from "./grok/models"; import { createHeadlessJsonlEmitter, type HeadlessOutputFormat, @@ -118,7 +118,7 @@ async function runBackgroundDelegation(jobPath: string, options: Record) { const apiKey = options.apiKey || getApiKey(); const baseURL = options.baseUrl || getBaseURL(); - const model = options.model || getCurrentModel(); + const model = normalizeModelId(options.model || getCurrentModel()); const maxToolRounds = parseInt(options.maxToolRounds || "400", 10) || 400; if (options.apiKey) saveUserSettings({ apiKey: options.apiKey }); - if (options.model) saveUserSettings({ defaultModel: options.model }); + if (options.model) saveUserSettings({ defaultModel: normalizeModelId(options.model) }); return { apiKey, baseURL, model, maxToolRounds }; } @@ -240,11 +240,19 @@ program .action(() => { console.log("\nAvailable Grok Models:\n"); for (const m of MODELS) { - const reasoning = m.reasoning ? " (reasoning)" : ""; - console.log(` \x1b[36m${m.id}\x1b[0m — ${m.name}${reasoning}`); + const tags = [ + m.reasoning ? "reasoning" : "non-reasoning", + m.multiAgent ? "multi-agent" : null, + m.responsesOnly ? "responses-only" : null, + ].filter(Boolean); + const suffix = tags.length > 0 ? ` (${tags.join(", ")})` : ""; + console.log(` \x1b[36m${m.id}\x1b[0m — ${m.name}${suffix}`); console.log( ` ${m.description} | ${formatContext(m.contextWindow)} context | $${m.inputPrice}/$${m.outputPrice} per 1M tokens`, ); + if ((m.aliases?.length ?? 0) > 0) { + console.log(` aliases: ${(m.aliases ?? []).join(", ")}`); + } } console.log(); }); diff --git a/src/types/index.ts b/src/types/index.ts index 20f39a9..506697f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -107,6 +107,8 @@ export interface StreamChunk { toolResult?: ToolResult; } +export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; + export interface ModelInfo { id: string; name: string; @@ -115,6 +117,12 @@ export interface ModelInfo { outputPrice: number; reasoning: boolean; description: string; + aliases?: string[]; + responsesOnly?: boolean; + multiAgent?: boolean; + supportsClientTools?: boolean; + supportsMaxOutputTokens?: boolean; + defaultReasoningEffort?: ReasoningEffort; } export type AgentMode = "agent" | "plan" | "ask"; diff --git a/src/ui/app.tsx b/src/ui/app.tsx index bf837db..b28836e 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -4,7 +4,15 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react" import os from "os"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Agent } from "../agent/agent"; -import { getModelIds, getModelInfo, MODELS } from "../grok/models"; +import { + DEFAULT_MODEL, + getEffectiveReasoningEffort, + getModelIds, + getModelInfo, + getSupportedReasoningEfforts, + MODELS, + normalizeModelId, +} from "../grok/models"; import { POPULAR_MCP_CATALOG } from "../mcp/catalog"; import { parseEnvLines, parseHeaderLines } from "../mcp/parse-headers"; import { toMcpServerId, validateMcpServerConfig } from "../mcp/validate"; @@ -18,6 +26,7 @@ import type { ModelInfo, Plan, PlanQuestion, + ReasoningEffort, SubagentStatus, ToolCall, ToolResult, @@ -77,6 +86,14 @@ type ContextStats = { ratioUsed: number; ratioRemaining: number; }; +type PasteBlock = { id: number; content: string; lines: number; isImage?: boolean }; + +function getPasteBlockToken(block: Pick): string { + if (block.isImage) { + return `[Image #${block.id}]`; + } + return `[Pasted #${block.id} ${block.lines}+ lines]`; +} const HERO_ROWS: Row[] = [ { @@ -325,16 +342,22 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const [showSlashMenu, setShowSlashMenu] = useState(false); const [slashMenuIndex, setSlashMenuIndex] = useState(0); const [slashSearchQuery, setSlashSearchQuery] = useState(""); - const [pasteBlocks, setPasteBlocks] = useState<{ id: number; content: string; lines: number; isImage?: boolean }[]>( - [], + const [reasoningEffortByModel, setReasoningEffortByModel] = useState>(() => + Object.fromEntries( + Object.entries(loadUserSettings().reasoningEffortByModel ?? {}).map(([modelId, effort]) => [ + normalizeModelId(modelId), + effort, + ]), + ), ); + const [pasteBlocks, setPasteBlocks] = useState([]); const [activePlan, setActivePlan] = useState(null); /** Incremented on each successful TUI copy; drives a brief "Copied" banner. */ const [copyFlashId, setCopyFlashId] = useState(0); const [activeSubagent, setActiveSubagent] = useState(null); const [pqs, setPqs] = useState(initialPlanQuestionsState()); - const imageCounterRef = useRef(0); const pasteCounterRef = useRef(0); + const pasteBlocksRef = useRef([]); const apiKeyInputRef = useRef(null); const inputRef = useRef(null); const scrollRef = useRef(null); @@ -394,7 +417,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const [agentsEditorModelIndex, setAgentsEditorModelIndex] = useState(() => Math.max( 0, - MODELS.findIndex((model) => model.id === "grok-4-1-fast"), + MODELS.findIndex((model) => model.id === DEFAULT_MODEL), ), ); const [agentsEditorSyncKey, setAgentsEditorSyncKey] = useState(0); @@ -460,6 +483,51 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) saveMcpServers(servers); }, []); + const setReasoningEfforts = useCallback((next: Record) => { + setReasoningEffortByModel(next); + saveUserSettings({ reasoningEffortByModel: next }); + }, []); + + const replacePasteBlocks = useCallback((next: PasteBlock[]) => { + pasteBlocksRef.current = next; + setPasteBlocks(next); + }, []); + + const getModelReasoningEffort = useCallback( + (modelId: string): ReasoningEffort | undefined => { + const normalizedModelId = normalizeModelId(modelId); + return getEffectiveReasoningEffort(normalizedModelId, reasoningEffortByModel[normalizedModelId]); + }, + [reasoningEffortByModel], + ); + + const adjustModelReasoningEffort = useCallback( + (modelId: string, direction: -1 | 1) => { + const normalizedModelId = normalizeModelId(modelId); + const supported = getSupportedReasoningEfforts(normalizedModelId); + if (supported.length === 0) return; + + const current = getModelReasoningEffort(normalizedModelId); + + if (!current) { + if (direction > 0) { + setReasoningEfforts({ ...reasoningEffortByModel, [normalizedModelId]: supported[0] }); + } + return; + } + + const currentIndex = supported.indexOf(current); + if (direction < 0 && currentIndex <= 0) { + const { [normalizedModelId]: _, ...rest } = reasoningEffortByModel; + setReasoningEfforts(rest); + } else { + const nextIndex = direction < 0 ? currentIndex - 1 : Math.min(supported.length - 1, currentIndex + 1); + setReasoningEfforts({ ...reasoningEffortByModel, [normalizedModelId]: supported[nextIndex] }); + } + }, + [getModelReasoningEffort, reasoningEffortByModel, setReasoningEfforts], + ); + const snapshotMcpEditorDraft = useCallback((): McpEditorDraft => { return { ...mcpEditorDraft, @@ -583,7 +651,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setAgentsEditorModelIndex( Math.max( 0, - MODELS.findIndex((model) => model.id === agent.model), + MODELS.findIndex((model) => model.id === normalizeModelId(agent.model)), ), ); } else { @@ -591,7 +659,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setAgentsEditorModelIndex( Math.max( 0, - MODELS.findIndex((model) => model.id === "grok-4-1-fast"), + MODELS.findIndex((model) => model.id === DEFAULT_MODEL), ), ); } @@ -1245,11 +1313,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setActiveSubagent(null); setActivePlan(null); setPqs(initialPlanQuestionsState()); - setPasteBlocks([]); + replacePasteBlocks([]); queuedMessagesRef.current = []; setQueuedMessages([]); - imageCounterRef.current = 0; - }, [agent]); + }, [agent, replacePasteBlocks]); const processMessage = useCallback( async (text: string) => { @@ -1972,6 +2039,13 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setModelPickerIndex((i) => Math.min(filteredModelIds.length - 1, i + 1)); return; } + if (key.name === "left" || key.name === "right") { + const sel = filteredModelIds[modelPickerIndex]; + if (sel) { + adjustModelReasoningEffort(sel, key.name === "left" ? -1 : 1); + } + return; + } if (key.name === "return") { const sel = filteredModelIds[modelPickerIndex]; if (sel) { @@ -2044,7 +2118,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const text = inputRef.current?.plainText || ""; if (text.trim()) { inputRef.current?.clear(); - setPasteBlocks([]); + replacePasteBlocks([]); } else { handleExit(); } @@ -2069,6 +2143,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) dismissPlan, editingSubagent, editSavedMcp, + adjustModelReasoningEffort, filteredModelIds, filteredSlashItems, handleExit, @@ -2086,6 +2161,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) openApiKeyModal, openCatalogMcp, openMcpEditor, + replacePasteBlocks, openSubagentEditor, submitTelegramPair, submitTelegramToken, @@ -2121,24 +2197,25 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) if (imageExts.test(trimmed) && !trimmed.includes("\n")) { event.preventDefault(); const id = ++pasteCounterRef.current; - const imgNum = ++imageCounterRef.current; - setPasteBlocks((prev) => [...prev, { id, content: trimmed, lines: 1, isImage: true }]); - inputRef.current?.insertText(`[Image ${imgNum}]`); + const block = { id, content: trimmed, lines: 1, isImage: true } satisfies PasteBlock; + replacePasteBlocks([...pasteBlocksRef.current, block]); + inputRef.current?.insertText(getPasteBlockToken(block)); return; } const lineCount = text.split("\n").length; if (lineCount < 2) return; event.preventDefault(); const id = ++pasteCounterRef.current; - setPasteBlocks((prev) => [...prev, { id, content: text, lines: lineCount }]); - inputRef.current?.insertText(`[Pasted ~${lineCount} lines]`); + const block = { id, content: text, lines: lineCount } satisfies PasteBlock; + replacePasteBlocks([...pasteBlocksRef.current, block]); + inputRef.current?.insertText(getPasteBlockToken(block)); }, - [openApiKeyModal], + [openApiKeyModal, replacePasteBlocks], ); const handleSubmit = useCallback(() => { const raw = inputRef.current?.plainText || ""; - if (!raw.trim() && pasteBlocks.length === 0) { + if (!raw.trim() && pasteBlocksRef.current.length === 0) { if (queuedMessagesRef.current.length > 0 && isProcessingRef.current) { interruptedRunIdRef.current = activeRunIdRef.current; setStreamContent(""); @@ -2151,16 +2228,10 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) } inputRef.current?.clear(); let message = raw; - const blocks = [...pasteBlocks]; - let imgIdx = 0; - setPasteBlocks([]); + const blocks = [...pasteBlocksRef.current]; + replacePasteBlocks([]); for (const block of blocks) { - if (block.isImage) { - imgIdx++; - message = message.replace(`[Image ${imgIdx}]`, block.content); - } else { - message = message.replace(`[Pasted ~${block.lines} lines]`, block.content); - } + message = message.replace(getPasteBlockToken(block), block.content); } if (!message.trim()) return; if (!hasApiKeyRef.current) { @@ -2175,7 +2246,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) return; } processMessage(message); - }, [agent, handleCommand, openApiKeyModal, processMessage, pasteBlocks, scrollToBottom]); + }, [agent, handleCommand, openApiKeyModal, processMessage, replacePasteBlocks, scrollToBottom]); const hasMessages = messages.length > 0 || streamContent || isProcessing; @@ -2387,6 +2458,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) height={height} searchQuery={modelSearchQuery} filteredModels={filteredModels} + reasoningEffortByModel={reasoningEffortByModel} /> )} {showConnectModal && ( @@ -3652,6 +3724,7 @@ function ModelPickerModal({ height, searchQuery, filteredModels, + reasoningEffortByModel, }: { t: Theme; currentModel: string; @@ -3660,6 +3733,7 @@ function ModelPickerModal({ height: number; searchQuery: string; filteredModels: ModelInfo[]; + reasoningEffortByModel: Record; }) { const listRef = useRef(null); useEffect(() => { @@ -3668,7 +3742,9 @@ function ModelPickerModal({ }, [selectedIndex, filteredModels]); const itemCount = Math.max(filteredModels.length, 1); - const contentHeight = itemCount + 5; + const selectedModel = filteredModels[selectedIndex]; + const selectedSupportsReasoning = !!selectedModel && getSupportedReasoningEfforts(selectedModel.id).length > 0; + const contentHeight = itemCount + 6; const maxH = Math.floor(height * 0.6); const panelHeight = Math.min(contentHeight, maxH); const top = bottomAlignedModalTop(height, panelHeight); @@ -3705,6 +3781,9 @@ function ModelPickerModal({ {filteredModels.map((m, idx) => { const selected = idx === selectedIndex; const current = m.id === currentModel; + const supportedReasoningEfforts = getSupportedReasoningEfforts(m.id); + const reasoningEffort = + getEffectiveReasoningEffort(m.id, reasoningEffortByModel[normalizeModelId(m.id)]) ?? "auto"; return ( - {m.name} + + {m.name} + {supportedReasoningEfforts.length > 0 ? ( + {`[${reasoningEffort}]`} + ) : null} + ); })} @@ -3723,6 +3808,11 @@ function ModelPickerModal({ )} + + + {selectedSupportsReasoning ? "left/right reasoning enter select esc close" : "enter select esc close"} + + ); diff --git a/src/utils/settings.ts b/src/utils/settings.ts index ac173ca..364dda0 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -1,7 +1,8 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { DEFAULT_MODEL, getModelIds } from "../grok/models"; +import { DEFAULT_MODEL, getEffectiveReasoningEffort, getModelIds, normalizeModelId } from "../grok/models"; +import type { ReasoningEffort } from "../types/index"; export type TelegramStreamingMode = "off" | "partial"; @@ -60,7 +61,7 @@ export function parseSubAgentsRawList(raw: unknown): CustomSubagentConfig[] { const entry = item as Record; const name = typeof entry.name === "string" ? entry.name.trim() : ""; - const model = typeof entry.model === "string" ? entry.model.trim() : ""; + const model = typeof entry.model === "string" ? normalizeModelId(entry.model) : ""; const instruction = typeof entry.instruction === "string" ? entry.instruction : ""; if (!name || isReservedSubagentName(name) || !validModels.has(model)) { @@ -84,6 +85,7 @@ export function loadValidSubAgents(): CustomSubagentConfig[] { export interface UserSettings { apiKey?: string; defaultModel?: string; + reasoningEffortByModel?: Record; telegram?: TelegramSettings; mcp?: McpSettings; subAgents?: CustomSubagentConfig[]; @@ -126,7 +128,17 @@ export function saveUserSettings(partial: Partial): void { ...current, ...partial, ...(partial.apiKey !== undefined ? { apiKey: partial.apiKey } : {}), - ...(partial.defaultModel !== undefined ? { defaultModel: partial.defaultModel } : {}), + ...(partial.defaultModel !== undefined ? { defaultModel: normalizeModelId(partial.defaultModel) } : {}), + ...(partial.reasoningEffortByModel !== undefined + ? { + reasoningEffortByModel: Object.fromEntries( + Object.entries(partial.reasoningEffortByModel).map(([modelId, effort]) => [ + normalizeModelId(modelId), + effort, + ]), + ), + } + : {}), ...(partial.telegram !== undefined ? { telegram: { @@ -148,7 +160,14 @@ export function saveUserSettings(partial: Partial): void { }, } : {}), - ...(partial.subAgents !== undefined ? { subAgents: partial.subAgents } : {}), + ...(partial.subAgents !== undefined + ? { + subAgents: partial.subAgents.map((agent) => ({ + ...agent, + model: normalizeModelId(agent.model), + })), + } + : {}), }; writeJson(USER_SETTINGS_PATH, next); @@ -162,7 +181,11 @@ export function loadProjectSettings(): ProjectSettings { export function saveProjectSettings(partial: Partial): void { const projectPath = path.join(process.cwd(), ".grok", "settings.json"); const current = loadProjectSettings(); - writeJson(projectPath, { ...current, ...partial }); + writeJson(projectPath, { + ...current, + ...partial, + ...(partial.model !== undefined ? { model: normalizeModelId(partial.model) } : {}), + }); } export function getApiKey(): string | undefined { @@ -174,11 +197,20 @@ export function getBaseURL(): string { } export function getCurrentModel(): string { - if (process.env.GROK_MODEL) return process.env.GROK_MODEL; + if (process.env.GROK_MODEL) return normalizeModelId(process.env.GROK_MODEL); const project = loadProjectSettings(); - if (project.model) return project.model; + if (project.model) return normalizeModelId(project.model); const user = loadUserSettings(); - return user.defaultModel || DEFAULT_MODEL; + return user.defaultModel ? normalizeModelId(user.defaultModel) : DEFAULT_MODEL; +} + +export function getReasoningEffortForModel(modelId: string): ReasoningEffort | undefined { + const normalizedModelId = normalizeModelId(modelId); + const savedEfforts = loadUserSettings().reasoningEffortByModel ?? {}; + const effort = + savedEfforts[normalizedModelId] ?? + Object.entries(savedEfforts).find(([savedModelId]) => normalizeModelId(savedModelId) === normalizedModelId)?.[1]; + return getEffectiveReasoningEffort(normalizedModelId, effort); } export function getTelegramBotToken(): string | undefined { diff --git a/src/utils/subagents-settings.test.ts b/src/utils/subagents-settings.test.ts index a5ebcb0..ba8a7ff 100644 --- a/src/utils/subagents-settings.test.ts +++ b/src/utils/subagents-settings.test.ts @@ -10,8 +10,18 @@ describe("parseSubAgentsRawList", () => { it("keeps valid entries with known model ids", () => { expect( - parseSubAgentsRawList([{ name: "docs", model: "grok-4-1-fast", instruction: "Focus on documentation." }]), - ).toEqual([{ name: "docs", model: "grok-4-1-fast", instruction: "Focus on documentation." }]); + parseSubAgentsRawList([ + { name: "docs", model: "grok-4-1-fast-reasoning", instruction: "Focus on documentation." }, + ]), + ).toEqual([{ name: "docs", model: "grok-4-1-fast-reasoning", instruction: "Focus on documentation." }]); + }); + + it("normalizes legacy aliases to canonical ids", () => { + expect( + parseSubAgentsRawList([ + { name: "research", model: "x-ai/grok-4.20-multi-agent-beta", instruction: "Focus on research." }, + ]), + ).toEqual([{ name: "research", model: "grok-4.20-multi-agent-0309", instruction: "Focus on research." }]); }); it("skips unknown models", () => { @@ -21,10 +31,10 @@ describe("parseSubAgentsRawList", () => { it("skips reserved and empty names", () => { expect( parseSubAgentsRawList([ - { name: "general", model: "grok-4-1-fast", instruction: "x" }, - { name: "Explore", model: "grok-4-1-fast", instruction: "x" }, - { name: "", model: "grok-4-1-fast", instruction: "x" }, - { name: " ", model: "grok-4-1-fast", instruction: "x" }, + { name: "general", model: "grok-4-1-fast-reasoning", instruction: "x" }, + { name: "Explore", model: "grok-4-1-fast-reasoning", instruction: "x" }, + { name: "", model: "grok-4-1-fast-reasoning", instruction: "x" }, + { name: " ", model: "grok-4-1-fast-reasoning", instruction: "x" }, ]), ).toEqual([]); }); @@ -35,7 +45,7 @@ describe("parseSubAgentsRawList", () => { { name: "Docs", model: "grok-4-1-fast", instruction: "first" }, { name: "docs", model: "grok-code-fast-1", instruction: "second" }, ]), - ).toEqual([{ name: "Docs", model: "grok-4-1-fast", instruction: "first" }]); + ).toEqual([{ name: "Docs", model: "grok-4-1-fast-reasoning", instruction: "first" }]); }); it("ignores non-object rows", () => {