diff --git a/README.md b/README.md index c103debfd..c45ba6fe4 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,26 @@ Names cannot be `general`, `explore`, `vision`, `verify`, or `computer` because Optional: `**GROK_BASE_URL**` (default `https://api.x.ai/v1`), `**GROK_MODEL**`, `**GROK_MAX_TOKENS**`. +### MiniMax provider + +Select the MiniMax OpenAI-compatible provider with `--provider minimax` or `GROK_PROVIDER=minimax`. + +```bash +MINIMAX_API_KEY=your-key grok --provider minimax --model MiniMax-M3 +``` + +Supported text models: + +- `MiniMax-M3` with a 1,000,000-token context window and text, image, and video input metadata. +- `MiniMax-M2.7` with a 204,800-token context window and text input. + +`MINIMAX_REGION` selects the official endpoint: + +- `global_en` (default): `https://api.minimax.io/v1` +- `cn_zh`: `https://api.minimaxi.com/v1` + +Use `MINIMAX_BASE_URL` only when an explicit OpenAI-compatible proxy or endpoint override is required. MiniMax sessions support chat completions and local CLI tools. Provider-hosted search, media generation, batch requests, and Telegram audio transcription are disabled when the provider does not expose those capabilities. + --- ## Telegram (remote control) — short version diff --git a/bun.lock b/bun.lock index a77cbfb40..4f584e168 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "@vibe-kit/grok-cli", "dependencies": { "@ai-sdk/mcp": "^1.0.25", + "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider-utils": "^4.0.21", "@ai-sdk/xai": "^3.0.67", "@coinbase/agentkit": "^0.10.4", diff --git a/package.json b/package.json index 9d7937407..77752be7f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "license": "MIT", "dependencies": { "@ai-sdk/mcp": "^1.0.25", + "@ai-sdk/openai-compatible": "2.0.35", "@ai-sdk/provider-utils": "^4.0.21", "@ai-sdk/xai": "^3.0.67", "@coinbase/agentkit": "^0.10.4", diff --git a/src/agent/agent.ts b/src/agent/agent.ts index 51b066432..b6b6547eb 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -20,7 +20,7 @@ import { resolveModelRuntime, type XaiProvider, } from "../grok/client"; -import { DEFAULT_MODEL, getModelInfo, normalizeModelId } from "../grok/models"; +import { getModelInfo, getModelProvider, normalizeModelId } from "../grok/models"; import { toolSetToBatchTools } from "../grok/tool-schemas"; import { createTools } from "../grok/tools"; import { executeEventHooks } from "../hooks/index"; @@ -58,6 +58,7 @@ import type { AgentMode, ChatEntry, Plan, + ProviderKind, SessionInfo, SessionSnapshot, StreamChunk, @@ -100,15 +101,13 @@ import { containsEncryptedReasoning, sanitizeModelMessages } from "./reasoning"; import { buildVisionUserMessages } from "./vision-input"; const MAX_TOOL_ROUNDS = 400; -const VISION_MODEL = "grok-4.3"; -const COMPUTER_MODEL = "grok-4.3"; - interface AgentOptions { persistSession?: boolean; session?: string; sandboxMode?: SandboxMode; sandboxSettings?: SandboxSettings; batchApi?: boolean; + provider?: ProviderKind; } type ProcessMessageFinishReason = "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other"; @@ -532,8 +531,16 @@ function applyModelConstraints(system: string, modelId: string): string { ].join("\n"); } +function hasImageInput(message: ModelMessage): boolean { + if (!Array.isArray(message.content)) return false; + return message.content.some( + (part) => part.type === "file" && typeof part.mediaType === "string" && part.mediaType.startsWith("image/"), + ); +} + export class Agent { private provider: XaiProvider | null = null; + private providerKind: ProviderKind; private apiKey: string | null = null; private baseURL: string | null = null; private bash: BashTool; @@ -564,6 +571,9 @@ export class Agent { options: AgentOptions = {}, ) { this.baseURL = baseURL || null; + const initialMode: AgentMode = "agent"; + this.modelId = normalizeModelId(model || getCurrentModel(initialMode, options.provider)); + this.providerKind = options.provider ?? getModelProvider(this.modelId) ?? "xai"; if (apiKey) { this.setApiKey(apiKey, baseURL); } @@ -573,8 +583,6 @@ export class Agent { }); this.delegations = new DelegationManager(() => this.bash.getCwd()); - const initialMode: AgentMode = "agent"; - this.modelId = normalizeModelId(model || getCurrentModel(initialMode)); this.schedules = new ScheduleManager( () => this.bash.getCwd(), () => this.modelId, @@ -601,8 +609,17 @@ export class Agent { return this.modelId; } + getProviderKind(): ProviderKind { + return this.providerKind; + } + setModel(model: string): void { - this.modelId = normalizeModelId(model); + const modelId = normalizeModelId(model); + const modelProvider = getModelProvider(modelId); + if (modelProvider && modelProvider !== this.providerKind) { + throw new Error(`Model ${modelId} is not available from the ${this.providerKind} provider.`); + } + this.modelId = modelId; if (this.sessionStore && this.session) { this.sessionStore.setModel(this.session.id, this.modelId); this.session = this.sessionStore.getRequiredSession(this.session.id); @@ -632,9 +649,9 @@ export class Agent { setMode(mode: AgentMode): void { if (mode !== this.mode) { this.mode = mode; - const modeModel = getModeSpecificModel(mode); + const modeModel = getModeSpecificModel(mode, this.providerKind); if (modeModel) { - this.modelId = normalizeModelId(modeModel); + this.setModel(modeModel); } if (this.sessionStore && this.session) { this.sessionStore.setMode(this.session.id, mode); @@ -659,7 +676,7 @@ export class Agent { setApiKey(apiKey: string, baseURL = this.baseURL ?? undefined): void { this.apiKey = apiKey; this.baseURL = baseURL || null; - this.provider = createProvider(apiKey, baseURL); + this.provider = createProvider(apiKey, baseURL, this.providerKind); } getCwd(): string { @@ -954,12 +971,13 @@ export class Agent { } private getBatchClientOptions(signal?: AbortSignal): BatchClientOptions { - if (!this.apiKey) { - throw new Error("API key required. Add an API key to continue."); + const provider = this.requireProvider(); + if (!provider.capabilities.batchApi || !provider.getBatchClientApiKey) { + throw new Error(`Batch API is not supported by the ${provider.kind} provider.`); } return { - apiKey: this.apiKey, + apiKey: provider.getBatchClientApiKey(), baseURL: this.baseURL ?? undefined, signal, }; @@ -1078,7 +1096,7 @@ export class Agent { childRuntime.modelInfo?.supportsMaxOutputTokens === false ? undefined : Math.min(this.maxTokens, 8_192), - reasoningEffort: childRuntime.providerOptions?.xai.reasoningEffort, + reasoningEffort: childRuntime.providerOptions?.xai?.reasoningEffort, tools: batchTools, }), }, @@ -1181,7 +1199,7 @@ export class Agent { const isVerifyDetect = agentKey === "verify-detect"; const isVerifyManifest = agentKey === "verify-manifest"; const isComputer = agentKey === "computer"; - const subagents = loadValidSubAgents(); + const subagents = loadValidSubAgents(this.providerKind); const custom = !isExplore && !isGeneral && !isVision && !isVerify && !isVerifyDetect && !isVerifyManifest && !isComputer ? findCustomSubagent(agentKey, subagents) @@ -1249,18 +1267,20 @@ export class Agent { let closeMcp: (() => Promise) | undefined; const childModelId = normalizeModelId( isVision - ? VISION_MODEL + ? provider.defaultModelId : isComputer - ? COMPUTER_MODEL + ? provider.defaultModelId : isExplore - ? DEFAULT_MODEL + ? provider.defaultModelId : custom ? custom.model : this.modelId, ); - const childRuntime = isVision - ? { ...resolveModelRuntime(provider, childModelId), model: provider.responses(childModelId) } - : resolveModelRuntime(provider, childModelId); + const resolvedChildRuntime = resolveModelRuntime(provider, childModelId); + const childRuntime = + isVision && provider.responsesModel + ? { ...resolvedChildRuntime, model: provider.responsesModel(childModelId) } + : resolvedChildRuntime; if (isComputer && childRuntime.modelInfo?.supportsClientTools === false) { return { success: false, @@ -1657,7 +1677,7 @@ export class Agent { messages: [...this.messages, ...turnMessages], temperature: 0.7, maxOutputTokens: runtime.modelInfo?.supportsMaxOutputTokens === false ? undefined : this.maxTokens, - reasoningEffort: runtime.providerOptions?.xai.reasoningEffort, + reasoningEffort: runtime.providerOptions?.xai?.reasoningEffort, tools: batchTools, }), }, @@ -1856,7 +1876,7 @@ export class Agent { this.messageSeqs.push(null); const provider = this.requireProvider(); - const subagents = loadValidSubAgents(); + const subagents = loadValidSubAgents(this.providerKind); const system = applyModelConstraints( buildSystemPrompt( this.bash.getCwd(), @@ -1873,6 +1893,18 @@ export class Agent { this.planContext = null; let attemptedOverflowRecovery = false; + if (hasImageInput(userModelMessage) && modelInfo?.inputModalities && !modelInfo.inputModalities.includes("image")) { + yield { + type: "error", + content: `Model ${runtime.modelId} does not support image input. Select an image-capable model and retry.`, + }; + yield { type: "done" }; + if (this.abortController?.signal === signal) { + this.abortController = null; + } + return; + } + if (this.batchApi) { try { yield* this.processMessageBatchTurn({ diff --git a/src/agent/batch-mode.test.ts b/src/agent/batch-mode.test.ts index 887c95d5f..b82f164e0 100644 --- a/src/agent/batch-mode.test.ts +++ b/src/agent/batch-mode.test.ts @@ -79,7 +79,7 @@ afterEach(() => { }); describe("Agent batch mode", () => { - it("throws when a child batch response has no choices", async () => { + it("handles xAI batch responses and rejects unsupported providers", async () => { const { Agent, mocks } = await importAgentModuleWithBatchMocks(); const agent = new Agent("test-key", "https://api.x.ai/v1", undefined, undefined, { persistSession: false, @@ -126,5 +126,22 @@ describe("Agent batch mode", () => { expect(mocks.addBatchRequests).toHaveBeenCalled(); expect(mocks.pollBatchRequestResult).toHaveBeenCalled(); expect(mocks.getBatchChatCompletion).toHaveBeenCalled(); + + const createBatchCalls = mocks.createBatch.mock.calls.length; + const minimaxAgent = new Agent("test-key", "https://api.minimax.io/v1", "MiniMax-M3", undefined, { + persistSession: false, + provider: "minimax", + }); + const getBatchClientOptions = ( + minimaxAgent as unknown as { getBatchClientOptions: () => unknown } + ).getBatchClientOptions.bind(minimaxAgent); + + expect(getBatchClientOptions).toThrow("Batch API is not supported by the minimax provider."); + expect(mocks.createBatch).toHaveBeenCalledTimes(createBatchCalls); + expect(() => minimaxAgent.setModel("grok-4.3")).toThrow( + "Model grok-4.3 is not available from the minimax provider.", + ); + expect(minimaxAgent.getProviderKind()).toBe("minimax"); + expect(minimaxAgent.getModel()).toBe("MiniMax-M3"); }); }); diff --git a/src/audio/stt/engine.ts b/src/audio/stt/engine.ts index 26ba9f81f..1a5c21499 100644 --- a/src/audio/stt/engine.ts +++ b/src/audio/stt/engine.ts @@ -1,5 +1,6 @@ +import type { ProviderKind } from "../../types/index"; import type { TelegramSettings } from "../../utils/settings"; -import { getApiKey, getBaseURL, resolveTelegramAudioInputSettings } from "../../utils/settings"; +import { getActiveProvider, getApiKey, getBaseURL, resolveTelegramAudioInputSettings } from "../../utils/settings"; import { GrokSttEngine, type GrokSttTranscriptionResult } from "./grok-stt"; export interface AudioTranscriptionInput { @@ -16,9 +17,13 @@ export interface AudioTranscriptionEngine { export function createTelegramAudioInputEngine( telegramSettings: TelegramSettings | undefined, + provider: ProviderKind = getActiveProvider(), ): AudioTranscriptionEngine { + if (provider !== "xai") { + throw new Error(`Telegram audio transcription is not supported by the ${provider} provider.`); + } const resolved = resolveTelegramAudioInputSettings(telegramSettings); - const apiKey = getApiKey(); + const apiKey = getApiKey(provider); if (!apiKey) { throw new Error( "Grok STT requires an API key. Set GROK_API_KEY or configure apiKey in ~/.grok/user-settings.json.", @@ -27,7 +32,7 @@ export function createTelegramAudioInputEngine( return new GrokSttEngine({ apiKey, - baseURL: getBaseURL(), + baseURL: getBaseURL(provider), language: resolved.language, }); } diff --git a/src/audio/stt/grok-stt.test.ts b/src/audio/stt/grok-stt.test.ts index 59b154904..5e1ba95a7 100644 --- a/src/audio/stt/grok-stt.test.ts +++ b/src/audio/stt/grok-stt.test.ts @@ -2,8 +2,17 @@ import fs from "fs"; import os from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTelegramAudioInputEngine } from "./engine"; import { GrokSttEngine, inferMimeTypeFromFileName } from "./grok-stt"; +describe("createTelegramAudioInputEngine", () => { + it("rejects providers without audio transcription support before reading credentials", () => { + expect(() => createTelegramAudioInputEngine(undefined, "minimax")).toThrow( + "Telegram audio transcription is not supported by the minimax provider.", + ); + }); +}); + describe("GrokSttEngine", () => { let tempDir: string; const originalFetch = global.fetch; diff --git a/src/grok/client.test.ts b/src/grok/client.test.ts index 07c8dc033..182326592 100644 --- a/src/grok/client.test.ts +++ b/src/grok/client.test.ts @@ -1,8 +1,7 @@ -import { createXai } from "@ai-sdk/xai"; import type { generateText } from "ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as settings from "../utils/settings"; -import { generateRecap, resolveModelRuntime } from "./client"; +import { createProvider, generateRecap, resolveModelRuntime } from "./client"; const mockGenerateText = vi.hoisted(() => vi.fn()); @@ -13,10 +12,7 @@ vi.mock("ai", () => { }); describe("client", () => { - const mockProvider = createXai({ - apiKey: "test-key", - baseURL: "https://api.x.ai/v1", - }); + const mockProvider = createProvider("test-key", "https://api.x.ai/v1"); describe("generateRecap", () => { beforeEach(() => { diff --git a/src/grok/client.ts b/src/grok/client.ts index 7d5445846..b308e771c 100644 --- a/src/grok/client.ts +++ b/src/grok/client.ts @@ -1,16 +1,6 @@ -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.20-non-reasoning"; -const DEFAULT_RECAP_MODEL = "grok-4.20-non-reasoning"; +import { createProvider as createProviderAdapter, type ProviderAdapter, type ResolvedModelRuntime } from "../providers"; +import type { ProviderKind } from "../types/index"; interface GeneratedTextResult { modelId: string; @@ -29,45 +19,19 @@ export interface GeneratedRecap extends GeneratedTextResult { recap: string; } -export interface ResolvedModelRuntime { - model: GrokRuntimeModel; - modelId: string; - modelInfo?: ModelInfo; - providerOptions?: { - xai: { - reasoningEffort: ReasoningEffort; - }; - }; -} +export type XaiProvider = ProviderAdapter; +export type { ProviderAdapter, ResolvedModelRuntime } from "../providers"; -export function createProvider(apiKey: string, baseURL?: string): XaiProvider { - return createXai({ - apiKey, - baseURL: baseURL || process.env.GROK_BASE_URL || "https://api.x.ai/v1", - }); +export function createProvider(apiKey: string, baseURL?: string, kind: ProviderKind = "xai"): ProviderAdapter { + return createProviderAdapter({ kind, apiKey, baseURL }); } -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 function resolveModelRuntime(provider: ProviderAdapter, requestedModelId: string): ResolvedModelRuntime { + return provider.resolveRuntime(requestedModelId); } -export async function generateTitle(provider: XaiProvider, userMessage: string): Promise { - const runtime = resolveModelRuntime(provider, DEFAULT_TITLE_MODEL); +export async function generateTitle(provider: ProviderAdapter, userMessage: string): Promise { + const runtime = resolveModelRuntime(provider, provider.defaultTitleModelId); try { const { text, usage } = await generateText({ model: runtime.model, @@ -98,11 +62,11 @@ export async function generateTitle(provider: XaiProvider, userMessage: string): } export async function generateRecap( - provider: XaiProvider, + provider: ProviderAdapter, transcript: string, signal?: AbortSignal, ): Promise { - const runtime = resolveModelRuntime(provider, DEFAULT_RECAP_MODEL); + const runtime = resolveModelRuntime(provider, provider.defaultRecapModelId); try { const { text, usage } = await generateText({ model: runtime.model, diff --git a/src/grok/media.test.ts b/src/grok/media.test.ts index 1de1eb995..c271a124f 100644 --- a/src/grok/media.test.ts +++ b/src/grok/media.test.ts @@ -41,7 +41,12 @@ describe("media tools", () => { }); const provider = { - image: vi.fn((modelId: string) => ({ modelId })), + capabilities: { + imageGeneration: true, + videoGeneration: true, + }, + imageModel: vi.fn((modelId: string) => ({ modelId })), + videoModel: vi.fn((modelId: string) => ({ modelId })), }; const result = await generateImageTool( @@ -66,7 +71,7 @@ describe("media tools", () => { }, }, }); - expect(provider.image).toHaveBeenCalledWith("grok-imagine-image"); + expect(provider.imageModel).toHaveBeenCalledWith("grok-imagine-image"); const media = result.media ?? []; expect(media).toHaveLength(1); @@ -92,7 +97,12 @@ describe("media tools", () => { }); const provider = { - video: vi.fn((modelId: string) => ({ modelId })), + capabilities: { + imageGeneration: true, + videoGeneration: true, + }, + imageModel: vi.fn((modelId: string) => ({ modelId })), + videoModel: vi.fn((modelId: string) => ({ modelId })), }; const result = await generateVideoTool( @@ -124,7 +134,7 @@ describe("media tools", () => { }, }, }); - expect(provider.video).toHaveBeenCalledWith("grok-imagine-video"); + expect(provider.videoModel).toHaveBeenCalledWith("grok-imagine-video"); const prompt = generateVideoMock.mock.calls[0]?.[0]?.prompt as { image?: string }; expect(prompt.image?.startsWith("data:image/jpeg;base64,")).toBe(true); @@ -135,4 +145,17 @@ describe("media tools", () => { expect(media[0]?.path).toBe(path.resolve(tempDir, "clips/teaser.mp4")); expect(fs.existsSync(media[0]?.path ?? "")).toBe(true); }); + + it("returns a capability error instead of invoking media APIs for a chat-only provider", async () => { + const provider = { + kind: "minimax", + capabilities: { imageGeneration: false, videoGeneration: false }, + }; + + const result = await generateImageTool(provider as never, { prompt: "not supported" }, tempDir); + + expect(result.success).toBe(false); + expect(result.output).toContain("not supported by the minimax provider"); + expect(generateImageMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/grok/media.ts b/src/grok/media.ts index eafb036b9..fd35b74b9 100644 --- a/src/grok/media.ts +++ b/src/grok/media.ts @@ -69,10 +69,16 @@ export async function generateImageTool( abortSignal?: AbortSignal, ): Promise { try { + if (!provider.capabilities.imageGeneration || !provider.imageModel) { + return failureResult( + "Image generation failed", + `Image generation is not supported by the ${provider.kind} provider.`, + ); + } const source = input.source ? await resolveImageSource(input.source, cwd, abortSignal) : null; const prompt = source ? { text: input.prompt, images: [source.data] } : input.prompt; const response = await generateImage({ - model: provider.image(IMAGE_MODEL_ID), + model: provider.imageModel(IMAGE_MODEL_ID), prompt, n: input.n, aspectRatio: toSdkAspectRatio(input.aspect_ratio), @@ -127,10 +133,16 @@ export async function generateVideoTool( abortSignal?: AbortSignal, ): Promise { try { + if (!provider.capabilities.videoGeneration || !provider.videoModel) { + return failureResult( + "Video generation failed", + `Video generation is not supported by the ${provider.kind} provider.`, + ); + } const source = input.source ? await resolveImageSource(input.source, cwd, abortSignal) : null; const prompt = source ? { text: input.prompt, image: source.dataUrl } : input.prompt; const response = await generateVideo({ - model: provider.video(VIDEO_MODEL_ID), + model: provider.videoModel(VIDEO_MODEL_ID), prompt, duration: input.duration, aspectRatio: input.aspect_ratio, diff --git a/src/grok/models.ts b/src/grok/models.ts index 3b6e81c0e..b56a4ca56 100644 --- a/src/grok/models.ts +++ b/src/grok/models.ts @@ -1,4 +1,10 @@ -import type { ModelInfo, ReasoningEffort } from "../types/index"; +import type { ModelInfo, ProviderKind, ReasoningEffort } from "../types/index"; + +export const MINIMAX_BASE_URLS = { + global_en: "https://api.minimax.io/v1", + cn_zh: "https://api.minimaxi.com/v1", +} as const; +export type MiniMaxRegion = keyof typeof MINIMAX_BASE_URLS; export const MODELS: ModelInfo[] = [ { @@ -65,9 +71,37 @@ export const MODELS: ModelInfo[] = [ aliases: ["grok-3-mini-fast"], supportsReasoningEffort: true, }, + { + id: "MiniMax-M3", + name: "MiniMax M3", + provider: "minimax", + contextWindow: 1_000_000, + inputPrice: 0.6, + outputPrice: 2.4, + cacheReadPrice: 0.12, + cacheWritePrice: null, + inputModalities: ["text", "image", "video"], + thinking: ["adaptive", "disabled"], + reasoning: true, + description: "MiniMax multimodal flagship model", + }, + { + id: "MiniMax-M2.7", + name: "MiniMax M2.7", + provider: "minimax", + contextWindow: 204_800, + inputPrice: 0.3, + outputPrice: 1.2, + cacheReadPrice: 0.06, + cacheWritePrice: 0.375, + inputModalities: ["text"], + thinking: ["always_on"], + reasoning: true, + description: "MiniMax text reasoning model", + }, ]; -const PROVIDER_PREFIX_RE = /^(x-ai|xai)\//i; +const PROVIDER_PREFIX_RE = /^(x-ai|xai|minimax)\//i; const aliasMap = new Map(); for (const model of MODELS) { @@ -78,6 +112,10 @@ for (const model of MODELS) { } export const DEFAULT_MODEL = MODELS.find((model) => model.id === "grok-4.3")?.id ?? MODELS[0]?.id ?? "grok-4.3"; +export const DEFAULT_MODEL_BY_PROVIDER: Record = { + xai: DEFAULT_MODEL, + minimax: "MiniMax-M3", +}; export function normalizeModelId(modelId: string): string { const trimmed = modelId.trim(); @@ -92,8 +130,17 @@ export function getModelInfo(modelId: string): ModelInfo | undefined { return MODELS.find((m) => m.id === normalized); } -export function getModelIds(): string[] { - return MODELS.map((m) => m.id); +export function getModelIds(provider?: ProviderKind): string[] { + return MODELS.filter((model) => !provider || (model.provider ?? "xai") === provider).map((model) => model.id); +} + +export function getModelProvider(modelId: string): ProviderKind | undefined { + const modelInfo = getModelInfo(modelId); + return modelInfo ? (modelInfo.provider ?? "xai") : undefined; +} + +export function getDefaultModel(provider: ProviderKind): string { + return DEFAULT_MODEL_BY_PROVIDER[provider]; } export function isKnownModelId(modelId: string): boolean { diff --git a/src/grok/tools.ts b/src/grok/tools.ts index 1e9e69c07..ea045fa39 100644 --- a/src/grok/tools.ts +++ b/src/grok/tools.ts @@ -62,14 +62,23 @@ export function createTools( abortSignal?: AbortSignal, ): Promise<{ success: boolean; output: string }> => { try { + const responsesModel = provider.responsesModel?.bind(provider); + const hostedTools = provider.hostedTools; + if (!provider.capabilities.hostedSearch || !responsesModel || !hostedTools) { + const label = toolName === "web_search" ? "Web search" : "X search"; + return { + success: false, + output: `${label} is not supported by the ${provider.kind} provider.`, + }; + } const { text } = await generateText({ - model: provider.responses(RESPONSES_SEARCH_MODEL), + model: responsesModel(RESPONSES_SEARCH_MODEL), maxOutputTokens: 4096, prompt: query, abortSignal, tools: { - ...(toolName === "web_search" ? { web_search: provider.tools.webSearch() } : {}), - ...(toolName === "x_search" ? { x_search: provider.tools.xSearch() } : {}), + ...(toolName === "web_search" ? { web_search: hostedTools.webSearch() } : {}), + ...(toolName === "x_search" ? { x_search: hostedTools.xSearch() } : {}), }, }); diff --git a/src/index.ts b/src/index.ts index d49af0341..2118b30f4 100755 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import readline from "readline"; import packageJson from "../package.json"; import { Agent } from "./agent/agent"; import { completeDelegation, failDelegation, loadDelegation } from "./agent/delegations"; -import { MODELS, normalizeModelId } from "./grok/models"; +import { getModelProvider, MODELS, normalizeModelId } from "./grok/models"; import { createHeadlessJsonlEmitter, type HeadlessOutputFormat, @@ -15,11 +15,14 @@ import { } from "./headless/output"; import { runTelegramHeadlessBridge } from "./telegram/headless-bridge"; import { startScheduleDaemon } from "./tools/schedule"; +import type { ProviderKind } from "./types/index"; import { processAtMentions } from "./utils/at-mentions.js"; import { runScriptManagedUninstall } from "./utils/install-manager"; import { + getActiveProvider, getApiKey, getBaseURL, + getCurrentModel, getCurrentSandboxMode, getCurrentSandboxSettings, loadPaymentSettings, @@ -59,7 +62,8 @@ process.on("unhandledRejection", (reason) => { async function startInteractive( apiKey: string | undefined, baseURL: string, - model: string | undefined, + model: string, + provider: ProviderKind, maxToolRounds: number, batchApi: boolean, sandboxMode: SandboxMode, @@ -67,7 +71,13 @@ async function startInteractive( session?: string, initialMessage?: string, ) { - const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { session, sandboxMode, sandboxSettings, batchApi }); + const agent = new Agent(apiKey, baseURL, model, maxToolRounds, { + session, + sandboxMode, + sandboxSettings, + batchApi, + provider, + }); const { createCliRenderer } = await import("@opentui/core"); const { createRoot } = await import("@opentui/react"); const { createElement } = await import("react"); @@ -96,6 +106,7 @@ async function startInteractive( apiKey, baseURL, model: agent.getModel(), + provider, maxToolRounds, sandboxMode, sandboxSettings, @@ -111,7 +122,8 @@ async function runHeadless( prompt: string, apiKey: string, baseURL: string, - model: string | undefined, + model: string, + provider: ProviderKind, maxToolRounds: number, batchApi: boolean, sandboxMode: SandboxMode, @@ -124,6 +136,7 @@ async function runHeadless( sandboxMode, sandboxSettings, batchApi, + provider, }); const prelude = renderHeadlessPrelude(format, agent.getSessionId() || undefined); if (prelude.stdout) process.stdout.write(prelude.stdout); @@ -254,14 +267,15 @@ async function runBackgroundDelegation(jobPath: string, options: CliOptions) { try { const delegation = await loadDelegation(jobPath); - const apiKey = stringOption(options.apiKey) || getApiKey(); + const explicitModel = stringOption(options.model) || delegation.model; + const model = normalizeModelId(explicitModel); + const provider = providerOption(options.provider) ?? getActiveProvider(model); + const apiKey = stringOption(options.apiKey) || getApiKey(provider); if (!apiKey) { - throw new Error("API key required. Set GROK_API_KEY, use --api-key, or save it to ~/.grok/user-settings.json."); + throw new Error(`API key required for the ${provider} provider.`); } - const baseURL = stringOption(options.baseUrl) || getBaseURL(); - const explicitModel = stringOption(options.model) || delegation.model; - const model = explicitModel ? normalizeModelId(explicitModel) : undefined; + const baseURL = stringOption(options.baseUrl) || getBaseURL(provider); const maxToolRounds = parseInt(stringOption(options.maxToolRounds) || String(delegation.maxToolRounds), 10) || delegation.maxToolRounds; const sandboxMode = resolveCliSandboxMode(options.sandbox) || delegation.sandboxMode || getCurrentSandboxMode(); @@ -271,6 +285,7 @@ async function runBackgroundDelegation(jobPath: string, options: CliOptions) { sandboxMode, sandboxSettings, batchApi: Boolean(delegation.batchApi ?? options.batchApi === true), + provider, }); const result = await agent.runTaskRequest({ agent: delegation.agent, @@ -300,10 +315,15 @@ async function runBackgroundDelegation(jobPath: string, options: CliOptions) { } function resolveConfig(options: CliOptions) { - const apiKey = stringOption(options.apiKey) || getApiKey(); - const baseURL = stringOption(options.baseUrl) || getBaseURL(); const explicitModel = stringOption(options.model); - const model = explicitModel ? normalizeModelId(explicitModel) : undefined; + const provider = providerOption(options.provider) ?? getActiveProvider(explicitModel); + const model = explicitModel ? normalizeModelId(explicitModel) : getCurrentModel(undefined, provider); + const modelProvider = getModelProvider(model); + if (modelProvider && modelProvider !== provider) { + throw new InvalidArgumentError(`Model ${model} is not available from the ${provider} provider.`); + } + const apiKey = stringOption(options.apiKey) || getApiKey(provider); + const baseURL = stringOption(options.baseUrl) || getBaseURL(provider); const maxToolRounds = parseInt(stringOption(options.maxToolRounds) || "400", 10) || 400; const sandboxMode = resolveCliSandboxMode(options.sandbox) || getCurrentSandboxMode(); @@ -320,23 +340,31 @@ function resolveConfig(options: CliOptions) { } const sandboxSettings = mergeSandboxSettings(getCurrentSandboxSettings(), cliOverrides); - if (typeof options.apiKey === "string") saveUserSettings({ apiKey: options.apiKey }); + if (typeof options.apiKey === "string") saveUserSettings({ apiKey: options.apiKey, provider }); + else if (typeof options.provider === "string" || (typeof options.model === "string" && modelProvider)) { + saveUserSettings({ provider }); + } if (typeof options.model === "string") saveUserSettings({ defaultModel: normalizeModelId(options.model) }); - return { apiKey, baseURL, model, maxToolRounds, sandboxMode, sandboxSettings }; + return { apiKey, baseURL, model, provider, maxToolRounds, sandboxMode, sandboxSettings }; } -function requireApiKey(apiKey: string | undefined): string { +function requireApiKey(apiKey: string | undefined, provider: ProviderKind): string { if (!apiKey) { - console.error( - "Error: API key required. Set GROK_API_KEY env var, use --api-key, or save to ~/.grok/user-settings.json", - ); + const environmentVariable = provider === "minimax" ? "MINIMAX_API_KEY" : "GROK_API_KEY"; + console.error(`Error: API key required. Set ${environmentVariable}, use --api-key, or save it in user settings.`); process.exit(1); } return apiKey; } +function providerOption(value: string | boolean | undefined): ProviderKind | undefined { + if (value === undefined) return undefined; + if (value === "xai" || value === "minimax") return value; + throw new InvalidArgumentError(`Invalid provider "${String(value)}". Expected "xai" or "minimax".`); +} + function parseHeadlessOutputFormat(value: string): HeadlessOutputFormat { if (isHeadlessOutputFormat(value)) { return value; @@ -350,9 +378,10 @@ program .description("AI coding agent powered by Grok — built with Bun and OpenTUI") .version(packageJson.version) .argument("[message...]", "Initial message to send") - .option("-k, --api-key ", "Grok API key") + .option("-k, --api-key ", "Provider API key") .option("-u, --base-url ", "API base URL") .option("-m, --model ", "Model to use") + .option("--provider ", "Provider to use: xai or minimax") .option("-d, --directory ", "Working directory", process.cwd()) .option("-p, --prompt ", "Run a single prompt headlessly") .option("--verify", "Run the built-in verify flow headlessly") @@ -393,9 +422,10 @@ program await runHeadless( buildVerifyPrompt(process.cwd()), - requireApiKey(config.apiKey), + requireApiKey(config.apiKey, config.provider), config.baseURL, config.model, + config.provider, config.maxToolRounds, options.batchApi === true, config.sandboxMode, @@ -409,9 +439,10 @@ program if (options.prompt) { await runHeadless( options.prompt, - requireApiKey(config.apiKey), + requireApiKey(config.apiKey, config.provider), config.baseURL, config.model, + config.provider, config.maxToolRounds, options.batchApi === true, config.sandboxMode, @@ -428,6 +459,7 @@ program config.apiKey, config.baseURL, config.model, + config.provider, config.maxToolRounds, options.batchApi === true, config.sandboxMode, @@ -440,9 +472,10 @@ program program .command("telegram-bridge") .description("Start the Telegram remote-control bridge without opening the TUI") - .option("-k, --api-key ", "Grok API key") + .option("-k, --api-key ", "Provider API key") .option("-u, --base-url ", "API base URL") .option("-m, --model ", "Model to use") + .option("--provider ", "Provider to use: xai or minimax") .option("-d, --directory ", "Working directory", process.cwd()) .option("--sandbox", "Run agent shell commands inside a Shuru sandbox") .option("--no-sandbox", "Run agent shell commands directly on the host") @@ -456,9 +489,10 @@ program process.off("SIGTERM", exitCleanlyOnSigterm); try { await runTelegramHeadlessBridge({ - apiKey: requireApiKey(config.apiKey), + apiKey: requireApiKey(config.apiKey, config.provider), baseURL: config.baseURL, model: config.model, + provider: config.provider, maxToolRounds: config.maxToolRounds, sandboxMode: config.sandboxMode, sandboxSettings: config.sandboxSettings, @@ -472,11 +506,12 @@ program program .command("models") - .description("List available Grok models") + .description("List available models") .action(() => { - console.log("\nAvailable Grok Models:\n"); + console.log("\nAvailable Models:\n"); for (const m of MODELS) { const tags = [ + m.provider ?? "xai", m.reasoning ? "reasoning" : "non-reasoning", m.multiAgent ? "multi-agent" : null, m.responsesOnly ? "responses-only" : null, diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 000000000..1a84c22e1 --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,30 @@ +import type { MiniMaxRegion } from "../grok/models"; +import { createMiniMaxAdapter } from "./minimax"; +import type { ProviderAdapter, ProviderFactoryConfig } from "./types"; +import { createXaiAdapter } from "./xai"; + +export type { MiniMaxRegion } from "../grok/models"; +export { createMiniMaxAdapter, MINIMAX_ENDPOINTS, resolveMiniMaxBaseURL, resolveMiniMaxRegion } from "./minimax"; +export type { + HostedToolNamespace, + ProviderAdapter, + ProviderCapabilities, + ProviderFactoryConfig, + ProviderRequestOptions, + ResolvedModelRuntime, +} from "./types"; +export { ProviderCapabilityError } from "./types"; +export { createXaiAdapter } from "./xai"; + +export function createProvider(config: ProviderFactoryConfig): ProviderAdapter { + switch (config.kind) { + case "xai": + return createXaiAdapter({ apiKey: config.apiKey, baseURL: config.baseURL }); + case "minimax": + return createMiniMaxAdapter({ + apiKey: config.apiKey, + baseURL: config.baseURL, + region: config.region as MiniMaxRegion | undefined, + }); + } +} diff --git a/src/providers/minimax.test.ts b/src/providers/minimax.test.ts new file mode 100644 index 000000000..8167f15d3 --- /dev/null +++ b/src/providers/minimax.test.ts @@ -0,0 +1,91 @@ +import type { FetchFunction } from "@ai-sdk/provider-utils"; +import { generateText } from "ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getModelInfo, normalizeModelId } from "../grok/models"; +import { createMiniMaxAdapter, MINIMAX_ENDPOINTS, resolveMiniMaxBaseURL, resolveMiniMaxRegion } from "./minimax"; + +describe("MiniMax provider adapter", () => { + afterEach(() => { + delete process.env.MINIMAX_REGION; + delete process.env.MINIMAX_BASE_URL; + }); + + it("uses the documented regional endpoints and validates the region", () => { + expect(MINIMAX_ENDPOINTS).toEqual({ + global_en: "https://api.minimax.io/v1", + cn_zh: "https://api.minimaxi.com/v1", + }); + expect(resolveMiniMaxRegion()).toBe("global_en"); + expect(resolveMiniMaxBaseURL(undefined, "cn_zh")).toBe("https://api.minimaxi.com/v1"); + expect(() => resolveMiniMaxRegion("unknown" as never)).toThrow(/MINIMAX_REGION/); + }); + + it("exposes the target model metadata and chat-only capabilities", () => { + const adapter = createMiniMaxAdapter({ apiKey: "test-key", region: "global_en" }); + const m3 = getModelInfo("MiniMax-M3"); + const m27 = getModelInfo("MiniMax-M2.7"); + + expect(adapter.kind).toBe("minimax"); + expect(adapter.capabilities).toEqual({ + responsesApi: false, + hostedSearch: false, + imageGeneration: false, + videoGeneration: false, + batchApi: false, + reasoningEffort: false, + audioStt: false, + }); + expect(m3).toMatchObject({ + id: "MiniMax-M3", + contextWindow: 1_000_000, + inputPrice: 0.6, + outputPrice: 2.4, + cacheReadPrice: 0.12, + cacheWritePrice: null, + inputModalities: ["text", "image", "video"], + thinking: ["adaptive", "disabled"], + }); + expect(m27).toMatchObject({ + id: "MiniMax-M2.7", + contextWindow: 204_800, + inputPrice: 0.3, + outputPrice: 1.2, + cacheReadPrice: 0.06, + cacheWritePrice: 0.375, + inputModalities: ["text"], + thinking: ["always_on"], + }); + expect(normalizeModelId("minimax/MiniMax-M3")).toBe("MiniMax-M3"); + expect(adapter.resolveRuntime("MiniMax-M3").modelId).toBe("MiniMax-M3"); + expect(adapter.responsesModel).toBeUndefined(); + expect(adapter.getBatchClientApiKey).toBeUndefined(); + }); + + it("sends the API key and selected endpoint through the OpenAI-compatible chat path", async () => { + const fetchMock = vi.fn(async (_input: unknown, _init?: unknown) => { + return new Response( + JSON.stringify({ + id: "chatcmpl-test", + model: "MiniMax-M3", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + const adapter = createMiniMaxAdapter({ + apiKey: "test-key", + region: "cn_zh", + fetch: fetchMock as unknown as FetchFunction, + }); + + const result = await generateText({ model: adapter.chatModel("MiniMax-M3"), prompt: "Say ok" }); + + expect(result.text).toBe("ok"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [input, init] = fetchMock.mock.calls[0] as [unknown, RequestInit]; + expect(String(input)).toBe("https://api.minimaxi.com/v1/chat/completions"); + expect(new Headers(init.headers).get("authorization")).toBe("Bearer test-key"); + expect(JSON.parse(String(init.body))).toMatchObject({ model: "MiniMax-M3" }); + }); +}); diff --git a/src/providers/minimax.ts b/src/providers/minimax.ts new file mode 100644 index 000000000..23352e092 --- /dev/null +++ b/src/providers/minimax.ts @@ -0,0 +1,82 @@ +import { createOpenAICompatible, type OpenAICompatibleProvider } from "@ai-sdk/openai-compatible"; +import type { FetchFunction } from "@ai-sdk/provider-utils"; +import { + getModelInfo, + getModelProvider, + MINIMAX_BASE_URLS, + type MiniMaxRegion, + normalizeModelId, +} from "../grok/models"; +import type { ProviderAdapter, ProviderCapabilities, ResolvedModelRuntime } from "./types"; + +export const MINIMAX_ENDPOINTS = MINIMAX_BASE_URLS; + +const MINIMAX_CAPABILITIES: ProviderCapabilities = { + responsesApi: false, + hostedSearch: false, + imageGeneration: false, + videoGeneration: false, + batchApi: false, + reasoningEffort: false, + audioStt: false, +}; + +const DEFAULT_MINIMAX_MODEL = "MiniMax-M3"; + +export interface MiniMaxAdapterOptions { + apiKey: string; + baseURL?: string; + region?: MiniMaxRegion; + fetch?: FetchFunction; +} + +export function resolveMiniMaxRegion(value = process.env.MINIMAX_REGION): MiniMaxRegion { + if (!value) return "global_en"; + if (value === "global_en" || value === "cn_zh") return value; + throw new Error('MINIMAX_REGION must be either "global_en" or "cn_zh".'); +} + +export function resolveMiniMaxBaseURL(baseURL?: string, region = resolveMiniMaxRegion()): string { + return (baseURL?.trim() || process.env.MINIMAX_BASE_URL?.trim() || MINIMAX_ENDPOINTS[region]).replace(/\/+$/, ""); +} + +class MiniMaxProviderAdapter implements ProviderAdapter { + readonly kind = "minimax" as const; + readonly capabilities = MINIMAX_CAPABILITIES; + readonly defaultModelId = DEFAULT_MINIMAX_MODEL; + readonly defaultTitleModelId = DEFAULT_MINIMAX_MODEL; + readonly defaultRecapModelId = DEFAULT_MINIMAX_MODEL; + + private readonly sdk: OpenAICompatibleProvider; + + constructor(options: MiniMaxAdapterOptions) { + this.sdk = createOpenAICompatible({ + name: "minimax", + apiKey: options.apiKey, + baseURL: resolveMiniMaxBaseURL(options.baseURL, options.region ?? resolveMiniMaxRegion()), + ...(options.fetch ? { fetch: options.fetch } : {}), + }); + } + + chatModel(modelId: string) { + return this.sdk.chatModel(modelId); + } + + resolveRuntime(requestedModelId: string): ResolvedModelRuntime { + const modelId = normalizeModelId(requestedModelId); + const modelInfo = getModelInfo(modelId); + if (modelInfo && getModelProvider(modelId) !== this.kind) { + throw new Error(`Model ${modelId} is not available from the ${this.kind} provider.`); + } + + return { + model: this.sdk.chatModel(modelId), + modelId, + modelInfo, + }; + } +} + +export function createMiniMaxAdapter(options: MiniMaxAdapterOptions): ProviderAdapter { + return new MiniMaxProviderAdapter(options); +} diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 000000000..7fac0f0e0 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,67 @@ +import type { Experimental_VideoModelV3 as VideoModel } from "@ai-sdk/provider"; +import type { ImageModel, LanguageModel, Tool } from "ai"; +import type { ModelInfo, ProviderKind, ReasoningEffort } from "../types/index"; + +export interface ProviderCapabilities { + responsesApi: boolean; + hostedSearch: boolean; + imageGeneration: boolean; + videoGeneration: boolean; + batchApi: boolean; + reasoningEffort: boolean; + audioStt: boolean; +} + +export type ProviderRequestOptions = { + xai?: { + reasoningEffort: ReasoningEffort; + }; +}; + +export interface ResolvedModelRuntime { + model: LanguageModel; + modelId: string; + modelInfo?: ModelInfo; + providerOptions?: ProviderRequestOptions; +} + +export interface HostedToolNamespace { + webSearch(): Tool; + xSearch(): Tool; +} + +export interface ProviderAdapter { + readonly kind: ProviderKind; + readonly capabilities: ProviderCapabilities; + readonly defaultModelId: string; + readonly defaultTitleModelId: string; + readonly defaultRecapModelId: string; + + chatModel(modelId: string): LanguageModel; + responsesModel?(modelId: string): LanguageModel; + imageModel?(modelId: string): ImageModel; + videoModel?(modelId: string): VideoModel; + hostedTools?: HostedToolNamespace; + resolveRuntime(requestedModelId: string): ResolvedModelRuntime; + getBatchClientApiKey?(): string; +} + +export interface ProviderFactoryConfig { + kind: ProviderKind; + apiKey: string; + baseURL?: string; + region?: "global_en" | "cn_zh"; +} + +export class ProviderCapabilityError extends Error { + readonly providerKind: ProviderKind; + readonly capability: keyof ProviderCapabilities; + + constructor(providerKind: ProviderKind, capability: keyof ProviderCapabilities, suggestion?: string) { + const base = `${capability} is not supported by the ${providerKind} provider.`; + super(suggestion ? `${base} ${suggestion}` : base); + this.name = "ProviderCapabilityError"; + this.providerKind = providerKind; + this.capability = capability; + } +} diff --git a/src/providers/xai.ts b/src/providers/xai.ts new file mode 100644 index 000000000..de7cb6209 --- /dev/null +++ b/src/providers/xai.ts @@ -0,0 +1,89 @@ +import { createXai } from "@ai-sdk/xai"; +import { getEffectiveReasoningEffort, getModelInfo, getModelProvider, normalizeModelId } from "../grok/models"; +import { getReasoningEffortForModel } from "../utils/settings"; +import type { HostedToolNamespace, ProviderAdapter, ProviderCapabilities, ResolvedModelRuntime } from "./types"; + +const DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"; +const DEFAULT_XAI_MODEL = "grok-4.3"; +const DEFAULT_XAI_AUXILIARY_MODEL = "grok-4.20-non-reasoning"; + +const XAI_CAPABILITIES: ProviderCapabilities = { + responsesApi: true, + hostedSearch: true, + imageGeneration: true, + videoGeneration: true, + batchApi: true, + reasoningEffort: true, + audioStt: true, +}; + +type XaiSdkProvider = ReturnType; + +export interface XaiAdapterOptions { + apiKey: string; + baseURL?: string; +} + +class XaiProviderAdapter implements ProviderAdapter { + readonly kind = "xai" as const; + readonly capabilities = XAI_CAPABILITIES; + readonly defaultModelId = DEFAULT_XAI_MODEL; + readonly defaultTitleModelId = DEFAULT_XAI_AUXILIARY_MODEL; + readonly defaultRecapModelId = DEFAULT_XAI_AUXILIARY_MODEL; + readonly hostedTools: HostedToolNamespace; + + private readonly sdk: XaiSdkProvider; + private readonly apiKey: string; + + constructor(options: XaiAdapterOptions) { + this.apiKey = options.apiKey; + this.sdk = createXai({ + apiKey: options.apiKey, + baseURL: options.baseURL || process.env.GROK_BASE_URL || DEFAULT_XAI_BASE_URL, + }); + this.hostedTools = { + webSearch: () => this.sdk.tools.webSearch(), + xSearch: () => this.sdk.tools.xSearch(), + }; + } + + chatModel(modelId: string) { + return this.sdk(modelId); + } + + responsesModel(modelId: string) { + return this.sdk.responses(modelId); + } + + imageModel(modelId: string) { + return this.sdk.image(modelId); + } + + videoModel(modelId: string) { + return this.sdk.video(modelId); + } + + resolveRuntime(requestedModelId: string): ResolvedModelRuntime { + const modelId = normalizeModelId(requestedModelId); + const modelInfo = getModelInfo(modelId); + if (modelInfo && getModelProvider(modelId) !== this.kind) { + throw new Error(`Model ${modelId} is not available from the ${this.kind} provider.`); + } + const reasoningEffort = getEffectiveReasoningEffort(modelId, getReasoningEffortForModel(modelId)); + + return { + model: modelInfo?.responsesOnly ? this.sdk.responses(modelId) : this.sdk(modelId), + modelId, + modelInfo, + providerOptions: reasoningEffort ? { xai: { reasoningEffort } } : undefined, + }; + } + + getBatchClientApiKey(): string { + return this.apiKey; + } +} + +export function createXaiAdapter(options: XaiAdapterOptions): ProviderAdapter { + return new XaiProviderAdapter(options); +} diff --git a/src/telegram/audio-input.ts b/src/telegram/audio-input.ts index 2ed825143..25e9494cd 100644 --- a/src/telegram/audio-input.ts +++ b/src/telegram/audio-input.ts @@ -2,6 +2,7 @@ import { mkdtemp, rm, writeFile } from "fs/promises"; import os from "os"; import path from "path"; import { createTelegramAudioInputEngine } from "../audio/stt/engine"; +import type { ProviderKind } from "../types/index"; import type { TelegramSettings } from "../utils/settings"; import { resolveTelegramAudioInputSettings } from "../utils/settings"; @@ -55,12 +56,14 @@ export async function transcribeTelegramAudioMessage(opts: { token: string; source: TelegramAudioSource; telegramSettings: TelegramSettings | undefined; + provider?: ProviderKind; }): Promise { const audioSettings = resolveTelegramAudioInputSettings(opts.telegramSettings); if (!audioSettings.enabled) { throw new Error("Telegram audio input is disabled in settings."); } + const engine = createTelegramAudioInputEngine(opts.telegramSettings, opts.provider); const tempDir = await mkdtemp(path.join(os.tmpdir(), "grok-telegram-audio-")); try { const file = await opts.api.getFile(opts.source.fileId); @@ -82,7 +85,6 @@ export async function transcribeTelegramAudioMessage(opts: { const bytes = Buffer.from(await response.arrayBuffer()); await writeFile(audioPath, bytes, { mode: 0o600 }); - const engine = createTelegramAudioInputEngine(opts.telegramSettings); const result = await engine.transcribe({ audioPath, fileName, diff --git a/src/telegram/bridge.ts b/src/telegram/bridge.ts index edb2f86e3..7624e6590 100644 --- a/src/telegram/bridge.ts +++ b/src/telegram/bridge.ts @@ -1,6 +1,6 @@ import { Bot } from "grammy"; import type { Agent } from "../agent/agent"; -import type { ToolCall, ToolResult } from "../types/index"; +import type { ProviderKind, ToolCall, ToolResult } from "../types/index"; import { loadUserSettings, resolveTelegramStreamSettings } from "../utils/settings"; import { getTelegramAudioSource, transcribeTelegramAudioMessage } from "./audio-input"; import { splitTelegramMessage, TELEGRAM_MAX_MESSAGE } from "./limits"; @@ -14,6 +14,7 @@ export { splitTelegramMessage, TELEGRAM_MAX_MESSAGE } from "./limits"; export interface TelegramBridgeOptions { token: string; + provider?: ProviderKind; getApprovedUserIds: () => number[]; coordinator: TurnCoordinator; getTelegramAgent: (userId: number) => Agent; @@ -201,6 +202,7 @@ export function createTelegramBridge(opts: TelegramBridgeOptions): TelegramBridg token: opts.token, source, telegramSettings: loadUserSettings().telegram, + provider: opts.provider, }); await runAgentTurn(ctx, userId, transcription.userContent, transcription.promptText); } catch (err: unknown) { diff --git a/src/telegram/headless-bridge.ts b/src/telegram/headless-bridge.ts index 756cf21d9..732008795 100644 --- a/src/telegram/headless-bridge.ts +++ b/src/telegram/headless-bridge.ts @@ -2,7 +2,9 @@ import * as fs from "node:fs"; import * as path from "node:path"; import process from "node:process"; import { Agent } from "../agent/agent"; +import type { ProviderKind } from "../types/index"; import { + getActiveProvider, getApiKey, getBaseURL, getCurrentModel, @@ -28,6 +30,7 @@ export interface TelegramHeadlessBridgeOptions { apiKey?: string; baseURL?: string; model?: string; + provider?: ProviderKind; sandboxMode?: SandboxMode; sandboxSettings?: SandboxSettings; maxToolRounds?: number; @@ -39,6 +42,7 @@ interface TelegramHeadlessStartupConfig { apiKey: string; baseURL: string; model: string; + provider: ProviderKind; sandboxMode: SandboxMode; sandboxSettings: SandboxSettings; maxToolRounds: number; @@ -80,7 +84,12 @@ function buildTelegramAgentFactory(startupConfig: TelegramHeadlessStartupConfig) startupConfig.baseURL, startupConfig.model, startupConfig.maxToolRounds, - { session: sessionId, sandboxMode: startupConfig.sandboxMode, sandboxSettings: startupConfig.sandboxSettings }, + { + session: sessionId, + sandboxMode: startupConfig.sandboxMode, + sandboxSettings: startupConfig.sandboxSettings, + provider: startupConfig.provider, + }, ); const nextSessionId = agent.getSessionId(); @@ -120,15 +129,17 @@ export async function runTelegramHeadlessBridge(options: TelegramHeadlessBridgeO throw new Error("Missing Telegram bot token in user settings or TELEGRAM_BOT_TOKEN."); } - const apiKey = options.apiKey ?? getApiKey(); + const provider = options.provider ?? getActiveProvider(options.model); + const apiKey = options.apiKey ?? getApiKey(provider); if (!apiKey) { - throw new Error("Missing Grok API key."); + throw new Error(`Missing API key for the ${provider} provider.`); } const startupConfig: TelegramHeadlessStartupConfig = { apiKey, - baseURL: options.baseURL ?? getBaseURL(), - model: options.model ?? getCurrentModel(), + baseURL: options.baseURL ?? getBaseURL(provider), + model: options.model ?? getCurrentModel(undefined, provider), + provider, sandboxMode: options.sandboxMode ?? getCurrentSandboxMode(), sandboxSettings: options.sandboxSettings ?? getCurrentSandboxSettings(), maxToolRounds: options.maxToolRounds ?? 400, @@ -145,6 +156,7 @@ export async function runTelegramHeadlessBridge(options: TelegramHeadlessBridgeO const getTelegramAgent = buildTelegramAgentFactory(startupConfig); const bridge = createTelegramBridge({ token, + provider, getApprovedUserIds: () => loadUserSettings().telegram?.approvedUserIds ?? [], coordinator, getTelegramAgent, diff --git a/src/types/index.ts b/src/types/index.ts index cc6e46850..0b2c6c9cd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -223,13 +223,21 @@ export interface StreamChunk { } export type ReasoningEffort = "low" | "medium" | "high" | "xhigh"; +export type ProviderKind = "xai" | "minimax"; +export type ModelInputModality = "text" | "image" | "video"; +export type ModelThinkingMode = "adaptive" | "disabled" | "always_on"; export interface ModelInfo { id: string; name: string; + provider?: ProviderKind; contextWindow: number; inputPrice: number; outputPrice: number; + cacheReadPrice?: number; + cacheWritePrice?: number | null; + inputModalities?: ModelInputModality[]; + thinking?: ModelThinkingMode[]; reasoning: boolean; description: string; aliases?: string[]; diff --git a/src/ui/agents-modal.tsx b/src/ui/agents-modal.tsx index 99233cb89..a47a4954e 100644 --- a/src/ui/agents-modal.tsx +++ b/src/ui/agents-modal.tsx @@ -1,6 +1,6 @@ import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"; import { type RefObject, useEffect, useRef } from "react"; -import { MODELS } from "../grok/models"; +import type { ModelInfo } from "../types/index"; import type { CustomSubagentConfig } from "../utils/settings"; import { formatSubagentName } from "../utils/subagent-display"; import type { Theme } from "./theme"; @@ -144,6 +144,7 @@ export function SubagentEditorModal({ draft, focusedField, modelIndex, + models, error, title, nameRef, @@ -157,6 +158,7 @@ export function SubagentEditorModal({ draft: { name: string; instruction: string }; focusedField: SubagentEditorField; modelIndex: number; + models: ModelInfo[]; error: string | null; title: string; nameRef: RefObject; @@ -164,7 +166,7 @@ export function SubagentEditorModal({ onSubmit: () => void; showRemoveHint?: boolean; }) { - const model = MODELS[modelIndex] ?? MODELS[0]; + const model = models[modelIndex] ?? models[0]; const panelWidth = Math.min(68, width - 6); const panelHeight = Math.min(28, Math.floor(height * 0.75)); const overlayBg = "#000000cc" as string; diff --git a/src/ui/app.tsx b/src/ui/app.tsx index 6c2eab442..df833f106 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -5,7 +5,6 @@ import os from "os"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Agent } from "../agent/agent"; import { - DEFAULT_MODEL, getEffectiveReasoningEffort, getModelIds, getModelInfo, @@ -27,6 +26,7 @@ import type { ModelInfo, Plan, PlanQuestion, + ProviderKind, ReasoningEffort, SubagentStatus, ToolCall, @@ -570,6 +570,7 @@ export interface AppStartupConfig { apiKey: string | undefined; baseURL: string; model: string; + provider: ProviderKind; sandboxMode: SandboxMode; sandboxSettings: SandboxSettings; maxToolRounds: number; @@ -596,6 +597,11 @@ interface ActiveTurnState { export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) { const t = dark; + const providerLabel = startupConfig.provider === "minimax" ? "MiniMax" : "xAI"; + const providerModels = useMemo( + () => MODELS.filter((candidate) => (candidate.provider ?? "xai") === startupConfig.provider), + [startupConfig.provider], + ); const renderer = useRenderer(); const initialHasApiKey = agent.hasApiKey(); const [hasApiKey, setHasApiKey] = useState(initialHasApiKey); @@ -720,7 +726,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const mcpEnvRef = useRef(null); const [showAgentsModal, setShowAgentsModal] = useState(false); const [showAgentsEditor, setShowAgentsEditor] = useState(false); - const [subAgents, setSubAgents] = useState(() => loadValidSubAgents()); + const [subAgents, setSubAgents] = useState(() => loadValidSubAgents(startupConfig.provider)); const [agentsSearchQuery, setAgentsSearchQuery] = useState(""); const [agentsModalIndex, setAgentsModalIndex] = useState(0); const [editingSubagent, setEditingSubagent] = useState(null); @@ -729,7 +735,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const [agentsEditorModelIndex, setAgentsEditorModelIndex] = useState(() => Math.max( 0, - MODELS.findIndex((model) => model.id === DEFAULT_MODEL), + providerModels.findIndex((candidate) => candidate.id === agent.getModel()), ), ); const [agentsEditorSyncKey, setAgentsEditorSyncKey] = useState(0); @@ -807,14 +813,14 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) modeInfoRef.current = modeInfo; const modelInfo = getModelInfo(model); const contextStats = modelInfo ? agent.getContextStats(modelInfo.contextWindow, streamContent) : null; - const _flatModels = MODELS.map((m) => m.id); + const _flatModels = providerModels.map((m) => m.id); const filteredModels = modelSearchQuery - ? MODELS.filter( + ? providerModels.filter( (m) => m.name.toLowerCase().includes(modelSearchQuery.toLowerCase()) || m.id.toLowerCase().includes(modelSearchQuery.toLowerCase()), ) - : MODELS; + : providerModels; const filteredModelIds = filteredModels.map((m) => m.id); const filteredSlashItems = filterSlashMenuItems(SLASH_MENU_ITEMS, slashSearchQuery); const mcpRows = buildMcpBrowseRows(mcpServers, POPULAR_MCP_CATALOG, mcpSearchQuery); @@ -1064,14 +1070,14 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) ); const openAgentsModal = useCallback(() => { - setSubAgents(loadValidSubAgents()); + setSubAgents(loadValidSubAgents(startupConfig.provider)); setAgentsSearchQuery(""); setAgentsModalIndex(0); setEditingSubagent(null); setAgentsEditorError(null); setShowAgentsEditor(false); setShowAgentsModal(true); - }, []); + }, [startupConfig.provider]); const openScheduleModal = useCallback(() => { void agent @@ -1137,36 +1143,39 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) [agent], ); - const openSubagentEditor = useCallback((agent: CustomSubagentConfig | null) => { - setEditingSubagent(agent); - if (agent) { - setAgentsEditorDraft({ name: agent.name, instruction: agent.instruction }); - setAgentsEditorModelIndex( - Math.max( - 0, - MODELS.findIndex((model) => model.id === normalizeModelId(agent.model)), - ), - ); - } else { - setAgentsEditorDraft({ name: "", instruction: "" }); - setAgentsEditorModelIndex( - Math.max( - 0, - MODELS.findIndex((model) => model.id === DEFAULT_MODEL), - ), - ); - } - setAgentsEditorField("name"); - setAgentsEditorError(null); - setAgentsEditorSyncKey((n) => n + 1); - setShowAgentsEditor(true); - setShowAgentsModal(true); - }, []); + const openSubagentEditor = useCallback( + (subagent: CustomSubagentConfig | null) => { + setEditingSubagent(subagent); + if (subagent) { + setAgentsEditorDraft({ name: subagent.name, instruction: subagent.instruction }); + setAgentsEditorModelIndex( + Math.max( + 0, + providerModels.findIndex((candidate) => candidate.id === normalizeModelId(subagent.model)), + ), + ); + } else { + setAgentsEditorDraft({ name: "", instruction: "" }); + setAgentsEditorModelIndex( + Math.max( + 0, + providerModels.findIndex((candidate) => candidate.id === agent.getModel()), + ), + ); + } + setAgentsEditorField("name"); + setAgentsEditorError(null); + setAgentsEditorSyncKey((n) => n + 1); + setShowAgentsEditor(true); + setShowAgentsModal(true); + }, + [agent, providerModels], + ); const submitSubagentEditor = useCallback(() => { const name = (subagentNameRef.current?.plainText || "").trim(); const instruction = subagentInstructionRef.current?.plainText || ""; - const model = MODELS[agentsEditorModelIndex]?.id; + const model = providerModels[agentsEditorModelIndex]?.id; if (!name) { setAgentsEditorError("Name is required."); @@ -1176,7 +1185,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setAgentsEditorError('Names "general" and "explore" are reserved.'); return; } - if (!model || !getModelIds().includes(model)) { + if (!model || !getModelIds(startupConfig.provider).includes(model)) { setAgentsEditorError("Pick a valid model."); return; } @@ -1194,23 +1203,23 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) next.push({ name, model, instruction }); saveUserSettings({ subAgents: next }); - setSubAgents(loadValidSubAgents()); + setSubAgents(loadValidSubAgents(startupConfig.provider)); setShowAgentsEditor(false); setEditingSubagent(null); setAgentsEditorError(null); - }, [agentsEditorModelIndex, editingSubagent, subAgents]); + }, [agentsEditorModelIndex, editingSubagent, providerModels, startupConfig.provider, subAgents]); const removeEditingSubagent = useCallback(() => { if (!editingSubagent) return; const next = subAgents.filter((item) => item.name !== editingSubagent.name); saveUserSettings({ subAgents: next }); - setSubAgents(loadValidSubAgents()); + setSubAgents(loadValidSubAgents(startupConfig.provider)); setShowAgentsEditor(false); setEditingSubagent(null); setAgentsEditorError(null); setAgentsModalIndex(0); - }, [editingSubagent, subAgents]); + }, [editingSubagent, startupConfig.provider, subAgents]); const submitMcpEditor = useCallback(() => { const draft: McpEditorDraft = { @@ -1596,9 +1605,9 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) return existing; } - const apiKey = getApiKey(); + const apiKey = getApiKey(startupConfig.provider); if (!apiKey) { - throw new Error("Grok API key required. Add it in the CLI or set GROK_API_KEY."); + throw new Error(`${providerLabel} API key required. Add it in the CLI or configure the provider key.`); } const u = loadUserSettings(); @@ -1607,6 +1616,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) session: sid, sandboxMode, sandboxSettings, + provider: startupConfig.provider, }); if (!sid && a.getSessionId()) { saveUserSettings({ @@ -1623,7 +1633,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) map.set(userId, a); return a; }, - [sandboxMode, sandboxSettings, startupConfig, wireTelegramAgentUi], + [providerLabel, sandboxMode, sandboxSettings, startupConfig, wireTelegramAgentUi], ); const appendTelegramUserMessage = useCallback( @@ -1705,11 +1715,12 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const startTelegramBridge = useCallback(() => { const token = getTelegramBotToken(); - if (!token || !getApiKey()) return; + if (!token || !getApiKey(startupConfig.provider)) return; if (bridgeRef.current) return; const bridge = createTelegramBridge({ token, + provider: startupConfig.provider, getApprovedUserIds: () => loadUserSettings().telegram?.approvedUserIds ?? [], coordinator: coordinatorRef.current, getTelegramAgent, @@ -1728,6 +1739,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) appendTelegramUserMessage, getTelegramAgent, showTelegramToolCalls, + startupConfig.provider, upsertTelegramAssistantMessage, ]); @@ -1789,12 +1801,12 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setApiKeyError("Enter an API key to continue."); return; } - if (!apiKey.startsWith("xai-")) { + if (startupConfig.provider === "xai" && !apiKey.startsWith("xai-")) { setApiKeyError("API keys should start with xai-."); return; } - saveUserSettings({ apiKey }); + saveUserSettings({ apiKey, provider: startupConfig.provider }); agent.setApiKey(apiKey); hasApiKeyRef.current = true; showApiKeyModalRef.current = false; @@ -1805,7 +1817,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) if (getTelegramBotToken()) { startTelegramBridge(); } - }, [agent, startTelegramBridge]); + }, [agent, startTelegramBridge, startupConfig.provider]); useEffect(() => { hasApiKeyRef.current = hasApiKey; @@ -1868,8 +1880,8 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) setTelegramTokenError("Paste your bot token from @BotFather."); return; } - if (!getApiKey()) { - setTelegramTokenError("Add a Grok API key first."); + if (!getApiKey(startupConfig.provider)) { + setTelegramTokenError("Add a provider API key first."); return; } const u = loadUserSettings(); @@ -1889,7 +1901,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) timestamp: new Date(), }, ]); - }, [startTelegramBridge]); + }, [startTelegramBridge, startupConfig.provider]); const submitTelegramPair = useCallback(async () => { const code = (telegramPairInputRef.current?.plainText || "").trim(); @@ -1923,8 +1935,11 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) const beginTelegramFromConnect = useCallback(() => { setShowConnectModal(false); - if (!getApiKey()) { - setMessages((p) => [...p, { type: "assistant", content: "Add a Grok API key first.", timestamp: new Date() }]); + if (!getApiKey(startupConfig.provider)) { + setMessages((p) => [ + ...p, + { type: "assistant", content: "Add a provider API key first.", timestamp: new Date() }, + ]); openApiKeyModal(); return; } @@ -1957,7 +1972,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) }, ]); } - }, [openApiKeyModal, startTelegramBridge]); + }, [openApiKeyModal, startTelegramBridge, startupConfig.provider]); const interruptActiveRun = useCallback( (key?: KeyEvent) => { @@ -2720,7 +2735,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) ) { const decrement = key.name === "up" || key.name === "left" || key.name === "k"; setAgentsEditorModelIndex((index) => - decrement ? Math.max(0, index - 1) : Math.min(MODELS.length - 1, index + 1), + decrement ? Math.max(0, index - 1) : Math.min(providerModels.length - 1, index + 1), ); return; } @@ -3324,6 +3339,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) showSandboxPicker, pendingPaymentApproval, processMessage, + providerModels.length, showWalletPicker, walletSettings, walletFocusIndex, @@ -3593,6 +3609,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) height={height} inputRef={apiKeyInputRef} error={apiKeyError} + provider={startupConfig.provider} onSubmit={submitApiKey} /> )} @@ -3674,6 +3691,7 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) draft={agentsEditorDraft} focusedField={agentsEditorField} modelIndex={agentsEditorModelIndex} + models={providerModels} error={agentsEditorError} title={editingSubagent ? `Edit sub-agent: ${formatSubagentName(editingSubagent.name)}` : "Add sub-agent"} nameRef={subagentNameRef} @@ -4111,6 +4129,7 @@ function ApiKeyModal({ height, inputRef, error, + provider, onSubmit, }: { t: Theme; @@ -4118,12 +4137,14 @@ function ApiKeyModal({ height: number; inputRef: React.RefObject; error: string | null; + provider: ProviderKind; onSubmit: () => void; }) { const overlayBg = "#000000cc" as string; const panelWidth = Math.min(68, width - 6); const panelHeight = 13; const top = bottomAlignedModalTop(height, panelHeight); + const providerLabel = provider === "minimax" ? "MiniMax" : "xAI"; return ( {"esc"} - {"Paste your xAI API key to unlock chat. You can hide this prompt with esc."} + {`Paste your ${providerLabel} API key to unlock chat. You can hide this prompt with esc.`}