Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 55 additions & 23 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -58,6 +58,7 @@ import type {
AgentMode,
ChatEntry,
Plan,
ProviderKind,
SessionInfo,
SessionSnapshot,
StreamChunk,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
}),
},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1249,18 +1267,20 @@ export class Agent {
let closeMcp: (() => Promise<void>) | 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,
Expand Down Expand Up @@ -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,
}),
},
Expand Down Expand Up @@ -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(),
Expand All @@ -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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rejected image stays in history

Medium Severity

The unsupported-image guard pushes the user message into this.messages and returns without discarding it. On text-only models such as MiniMax-M2.7, later turns still include that image payload and can fail even after the user retries with plain text.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 700d574. Configure here.


if (this.batchApi) {
try {
yield* this.processMessageBatchTurn({
Expand Down
19 changes: 18 additions & 1 deletion src/agent/batch-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
});
});
11 changes: 8 additions & 3 deletions src/audio/stt/engine.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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.",
Expand All @@ -27,7 +32,7 @@ export function createTelegramAudioInputEngine(

return new GrokSttEngine({
apiKey,
baseURL: getBaseURL(),
baseURL: getBaseURL(provider),
language: resolved.language,
});
}
9 changes: 9 additions & 0 deletions src/audio/stt/grok-stt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 2 additions & 6 deletions src/grok/client.test.ts
Original file line number Diff line number Diff line change
@@ -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());

Expand All @@ -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(() => {
Expand Down
Loading