Add Mistral as AI provider

- Add Mistral to KnownProvider type and model generation
- Implement Mistral-specific compat handling in openai-completions:
  - requiresToolResultName: tool results need name field
  - requiresAssistantAfterToolResult: synthetic assistant message between tool/user
  - requiresThinkingAsText: thinking blocks as <thinking> text
  - requiresMistralToolIds: tool IDs must be exactly 9 alphanumeric chars
- Add MISTRAL_API_KEY environment variable support
- Add Mistral tests across all test files
- Update documentation (README, CHANGELOG) for both ai and coding-agent packages
- Remove client IDs from gemini.md, reference upstream source instead

Closes #165
This commit is contained in:
Mario Zechner 2025-12-10 20:36:19 +01:00
parent a248e2547a
commit 99b4b1aca0
31 changed files with 1856 additions and 282 deletions

View file

@ -1,38 +0,0 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import * as crypto from "node:crypto";
import type { HookAPI } from "./packages/coding-agent/src/index.js";
export default function (pi: HookAPI) {
pi.on("session_start", async (_event, ctx) => {
const desktop = path.join(os.homedir(), "Desktop");
const seen = new Set(fs.readdirSync(desktop).filter((f) => f.endsWith(".png")));
ctx.ui.notify(`Watching ${desktop} for new .png files`, "info");
fs.watch(desktop, (event, file) => {
if (!file?.endsWith(".png") || event !== "rename" || seen.has(file)) return;
setTimeout(() => {
const filePath = path.join(desktop, file);
if (!fs.existsSync(filePath)) return;
seen.add(file);
const content = fs.readFileSync(filePath);
const stats = fs.statSync(filePath);
pi.send(`Use \`sag\` (no say!) to describe the image. Make it concise and hilarious`, [
{
id: crypto.randomUUID(),
type: "image",
fileName: file,
mimeType: "image/png",
size: stats.size,
content: content.toString("base64"),
},
]);
}, 500);
});
});
}

19
package-lock.json generated
View file

@ -983,6 +983,24 @@
"resolved": "packages/web-ui",
"link": true
},
"node_modules/@mistralai/mistralai": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.10.0.tgz",
"integrity": "sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==",
"dependencies": {
"zod": "^3.20.0",
"zod-to-json-schema": "^3.24.1"
}
},
"node_modules/@mistralai/mistralai/node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.84",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.84.tgz",
@ -6559,6 +6577,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@google/genai": "1.31.0",
"@mistralai/mistralai": "1.10.0",
"@sinclair/typebox": "^0.34.41",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- **Mistral provider**: Added support for Mistral AI models via the OpenAI-compatible API. Includes automatic handling of Mistral-specific requirements (tool call ID format, message ordering constraints). Set `MISTRAL_API_KEY` environment variable to use.
### Fixed
- Fixed bug where `ANTHROPIC_API_KEY` environment variable was deleted globally after first OAuth token usage, causing subsequent prompts to fail

View file

@ -9,6 +9,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
- **OpenAI**
- **Anthropic**
- **Google**
- **Mistral**
- **Groq**
- **Cerebras**
- **xAI**
@ -564,7 +565,7 @@ A **provider** offers models through a specific API. For example:
- **Anthropic** models use the `anthropic-messages` API
- **Google** models use the `google-generative-ai` API
- **OpenAI** models use the `openai-responses` API
- **xAI, Cerebras, Groq, etc.** models use the `openai-completions` API (OpenAI-compatible)
- **Mistral, xAI, Cerebras, Groq, etc.** models use the `openai-completions` API (OpenAI-compatible)
### Querying Providers and Models
@ -1036,6 +1037,7 @@ In Node.js environments, you can set environment variables to avoid passing API
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=...
MISTRAL_API_KEY=...
GROQ_API_KEY=gsk_...
CEREBRAS_API_KEY=csk-...
XAI_API_KEY=xai-...

View file

@ -22,6 +22,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.71.2",
"@google/genai": "1.31.0",
"@mistralai/mistralai": "1.10.0",
"@sinclair/typebox": "^0.34.41",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",

View file

@ -277,6 +277,32 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
// Process Mistral models
if (data.mistral?.models) {
for (const [modelId, model] of Object.entries(data.mistral.models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
models.push({
id: modelId,
name: m.name || modelId,
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: m.reasoning === true,
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
cost: {
input: m.cost?.input || 0,
output: m.cost?.output || 0,
cacheRead: m.cost?.cache_read || 0,
cacheWrite: m.cost?.cache_write || 0,
},
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
}
}
console.log(`Loaded ${models.length} tool-capable models from models.dev`);
return models;
} catch (error) {

View file

@ -1989,6 +1989,416 @@ export const MODELS = {
contextWindow: 204800,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
"glm-4.6v": {
id: "glm-4.6v",
name: "GLM-4.6V",
api: "anthropic-messages",
provider: "zai",
baseUrl: "https://api.z.ai/api/anthropic",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.3,
output: 0.9,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 32768,
} satisfies Model<"anthropic-messages">,
},
mistral: {
"devstral-medium-2507": {
id: "devstral-medium-2507",
name: "Devstral Medium",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.4,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"mistral-large-2512": {
id: "mistral-large-2512",
name: "Mistral Large 3",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.5,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"open-mixtral-8x22b": {
id: "open-mixtral-8x22b",
name: "Mixtral 8x22B",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2,
output: 6,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 64000,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"ministral-8b-latest": {
id: "ministral-8b-latest",
name: "Ministral 8B",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.1,
output: 0.1,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"pixtral-large-latest": {
id: "pixtral-large-latest",
name: "Pixtral Large",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 2,
output: 6,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"ministral-3b-latest": {
id: "ministral-3b-latest",
name: "Ministral 3B",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.04,
output: 0.04,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"pixtral-12b": {
id: "pixtral-12b",
name: "Pixtral 12B",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.15,
output: 0.15,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"mistral-medium-2505": {
id: "mistral-medium-2505",
name: "Mistral Medium 3",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.4,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"labs-devstral-small-2512": {
id: "labs-devstral-small-2512",
name: "Devstral Small 2",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.1,
output: 0.3,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 256000,
} satisfies Model<"openai-completions">,
"devstral-medium-latest": {
id: "devstral-medium-latest",
name: "Devstral 2",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.4,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"devstral-small-2505": {
id: "devstral-small-2505",
name: "Devstral Small 2505",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.1,
output: 0.3,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"mistral-medium-2508": {
id: "mistral-medium-2508",
name: "Mistral Medium 3.1",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.4,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"mistral-small-latest": {
id: "mistral-small-latest",
name: "Mistral Small",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.1,
output: 0.3,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"magistral-small": {
id: "magistral-small",
name: "Magistral Small",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: true,
input: ["text"],
cost: {
input: 0.5,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"devstral-small-2507": {
id: "devstral-small-2507",
name: "Devstral Small",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.1,
output: 0.3,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"codestral-latest": {
id: "codestral-latest",
name: "Codestral",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.3,
output: 0.9,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"open-mixtral-8x7b": {
id: "open-mixtral-8x7b",
name: "Mixtral 8x7B",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.7,
output: 0.7,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32000,
maxTokens: 32000,
} satisfies Model<"openai-completions">,
"mistral-nemo": {
id: "mistral-nemo",
name: "Mistral Nemo",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.15,
output: 0.15,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 128000,
} satisfies Model<"openai-completions">,
"open-mistral-7b": {
id: "open-mistral-7b",
name: "Mistral 7B",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.25,
output: 0.25,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 8000,
maxTokens: 8000,
} satisfies Model<"openai-completions">,
"mistral-large-latest": {
id: "mistral-large-latest",
name: "Mistral Large",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.5,
output: 1.5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"mistral-medium-latest": {
id: "mistral-medium-latest",
name: "Mistral Medium",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.4,
output: 2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistral-large-2411": {
id: "mistral-large-2411",
name: "Mistral Large 2.1",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2,
output: 6,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"magistral-medium-latest": {
id: "magistral-medium-latest",
name: "Magistral Medium",
api: "openai-completions",
provider: "mistral",
baseUrl: "https://api.mistral.ai/v1",
reasoning: true,
input: ["text"],
cost: {
input: 2,
output: 5,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
},
openrouter: {
"mistralai/devstral-2512:free": {
@ -4448,13 +4858,13 @@ export const MODELS = {
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.136,
output: 0.6799999999999999,
input: 0.15,
output: 0.6,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1048576,
maxTokens: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-4-scout": {
id: "meta-llama/llama-4-scout",
@ -5068,23 +5478,6 @@ export const MODELS = {
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"mistralai/ministral-3b": {
id: "mistralai/ministral-3b",
name: "Mistral: Ministral 3B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.04,
output: 0.04,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/ministral-8b": {
id: "mistralai/ministral-8b",
name: "Mistral: Ministral 8B",
@ -5102,6 +5495,23 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/ministral-3b": {
id: "mistralai/ministral-3b",
name: "Mistral: Ministral 3B",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.04,
output: 0.04,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"nvidia/llama-3.1-nemotron-70b-instruct": {
id: "nvidia/llama-3.1-nemotron-70b-instruct",
name: "NVIDIA: Llama 3.1 Nemotron 70B Instruct",
@ -5272,6 +5682,23 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.02,
output: 0.03,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-405b-instruct": {
id: "meta-llama/llama-3.1-405b-instruct",
name: "Meta: Llama 3.1 405B Instruct",
@ -5306,23 +5733,6 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3.1-8b-instruct": {
id: "meta-llama/llama-3.1-8b-instruct",
name: "Meta: Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.02,
output: 0.03,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mistral-nemo": {
id: "mistralai/mistral-nemo",
name: "Mistral: Mistral Nemo",
@ -5459,6 +5869,23 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4o-2024-05-13": {
id: "openai/gpt-4o-2024-05-13",
name: "OpenAI: GPT-4o (2024-05-13)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
cost: {
input: 5,
output: 15,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4o": {
id: "openai/gpt-4o",
name: "OpenAI: GPT-4o",
@ -5493,22 +5920,22 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 64000,
} satisfies Model<"openai-completions">,
"openai/gpt-4o-2024-05-13": {
id: "openai/gpt-4o-2024-05-13",
name: "OpenAI: GPT-4o (2024-05-13)",
"meta-llama/llama-3-70b-instruct": {
id: "meta-llama/llama-3-70b-instruct",
name: "Meta: Llama 3 70B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text", "image"],
input: ["text"],
cost: {
input: 5,
output: 15,
input: 0.3,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
contextWindow: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3-8b-instruct": {
id: "meta-llama/llama-3-8b-instruct",
@ -5527,23 +5954,6 @@ export const MODELS = {
contextWindow: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"meta-llama/llama-3-70b-instruct": {
id: "meta-llama/llama-3-70b-instruct",
name: "Meta: Llama 3 70B Instruct",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.3,
output: 0.39999999999999997,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 8192,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mixtral-8x22b-instruct": {
id: "mistralai/mixtral-8x22b-instruct",
name: "Mistral: Mixtral 8x22B Instruct",
@ -5629,23 +6039,6 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4-turbo-preview": {
id: "openai/gpt-4-turbo-preview",
name: "OpenAI: GPT-4 Turbo Preview",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 10,
output: 30,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-3.5-turbo-0613": {
id: "openai/gpt-3.5-turbo-0613",
name: "OpenAI: GPT-3.5 Turbo (older v0613)",
@ -5663,6 +6056,23 @@ export const MODELS = {
contextWindow: 4095,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4-turbo-preview": {
id: "openai/gpt-4-turbo-preview",
name: "OpenAI: GPT-4 Turbo Preview",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 10,
output: 30,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mistral-tiny": {
id: "mistralai/mistral-tiny",
name: "Mistral Tiny",
@ -5731,6 +6141,23 @@ export const MODELS = {
contextWindow: 16385,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4-0314": {
id: "openai/gpt-4-0314",
name: "OpenAI: GPT-4 (older v0314)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 30,
output: 60,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 8191,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4": {
id: "openai/gpt-4",
name: "OpenAI: GPT-4",
@ -5765,23 +6192,6 @@ export const MODELS = {
contextWindow: 16385,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openai/gpt-4-0314": {
id: "openai/gpt-4-0314",
name: "OpenAI: GPT-4 (older v0314)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 30,
output: 60,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 8191,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"openrouter/auto": {
id: "openrouter/auto",
name: "OpenRouter: Auto Router",

View file

@ -6,6 +6,7 @@ import type {
ChatCompletionContentPartImage,
ChatCompletionContentPartText,
ChatCompletionMessageParam,
ChatCompletionToolMessageParam,
} from "openai/resources/chat/completions.js";
import { calculateCost } from "../models.js";
import type {
@ -27,6 +28,25 @@ import { parseStreamingJson } from "../utils/json-parse.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { transformMessages } from "./transorm-messages.js";
/**
* Normalize tool call ID for Mistral.
* Mistral requires tool IDs to be exactly 9 alphanumeric characters (a-z, A-Z, 0-9).
*/
function normalizeMistralToolId(id: string, isMistral: boolean): string {
if (!isMistral) return id;
// Remove non-alphanumeric characters
let normalized = id.replace(/[^a-zA-Z0-9]/g, "");
// Mistral requires exactly 9 characters
if (normalized.length < 9) {
// Pad with deterministic characters based on original ID to ensure matching
const padding = "ABCDEFGHI";
normalized = normalized + padding.slice(0, 9 - normalized.length);
} else if (normalized.length > 9) {
normalized = normalized.slice(0, 9);
}
return normalized;
}
/**
* Check if conversation messages contain tool calls or tool results.
* This is needed because Anthropic (via proxy) requires the tools param
@ -346,7 +366,18 @@ function convertMessages(
params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) });
}
let lastRole: string | null = null;
for (const msg of transformedMessages) {
// Some providers (e.g. Mistral) don't allow user messages directly after tool results
// Insert a synthetic assistant message to bridge the gap
if (compat.requiresAssistantAfterToolResult && lastRole === "toolResult" && msg.role === "user") {
params.push({
role: "assistant",
content: "I have processed the tool results.",
});
}
if (msg.role === "user") {
if (typeof msg.content === "string") {
params.push({
@ -379,9 +410,10 @@ function convertMessages(
});
}
} else if (msg.role === "assistant") {
// Some providers (e.g. Mistral) don't accept null content, use empty string instead
const assistantMsg: ChatCompletionAssistantMessageParam = {
role: "assistant",
content: null,
content: compat.requiresAssistantAfterToolResult ? "" : null,
};
const textBlocks = msg.content.filter((b) => b.type === "text") as TextContent[];
@ -391,20 +423,31 @@ function convertMessages(
});
}
// Handle thinking blocks for llama.cpp server + gpt-oss
// Handle thinking blocks
const thinkingBlocks = msg.content.filter((b) => b.type === "thinking") as ThinkingContent[];
if (thinkingBlocks.length > 0) {
// Use the signature from the first thinking block if available
const signature = thinkingBlocks[0].thinkingSignature;
if (signature && signature.length > 0) {
(assistantMsg as any)[signature] = thinkingBlocks.map((b) => b.thinking).join("\n");
if (compat.requiresThinkingAsText) {
// Convert thinking blocks to text with <thinking> delimiters
const thinkingText = thinkingBlocks.map((b) => `<thinking>\n${b.thinking}\n</thinking>`).join("\n");
const textContent = assistantMsg.content as Array<{ type: "text"; text: string }> | null;
if (textContent) {
textContent.unshift({ type: "text", text: thinkingText });
} else {
assistantMsg.content = [{ type: "text", text: thinkingText }];
}
} else {
// Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss)
const signature = thinkingBlocks[0].thinkingSignature;
if (signature && signature.length > 0) {
(assistantMsg as any)[signature] = thinkingBlocks.map((b) => b.thinking).join("\n");
}
}
}
const toolCalls = msg.content.filter((b) => b.type === "toolCall") as ToolCall[];
if (toolCalls.length > 0) {
assistantMsg.tool_calls = toolCalls.map((tc) => ({
id: tc.id,
id: normalizeMistralToolId(tc.id, compat.requiresMistralToolIds),
type: "function" as const,
function: {
name: tc.name,
@ -426,11 +469,16 @@ function convertMessages(
// Always send tool result with text (or placeholder if only images)
const hasText = textResult.length > 0;
params.push({
// Some providers (e.g. Mistral) require the 'name' field in tool results
const toolResultMsg: ChatCompletionToolMessageParam = {
role: "tool",
content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
tool_call_id: msg.toolCallId,
});
tool_call_id: normalizeMistralToolId(msg.toolCallId, compat.requiresMistralToolIds),
};
if (compat.requiresToolResultName && msg.toolName) {
(toolResultMsg as any).name = msg.toolName;
}
params.push(toolResultMsg);
// If there are images and model supports them, send a follow-up user message with images
if (hasImages && model.input.includes("image")) {
@ -462,6 +510,8 @@ function convertMessages(
});
}
}
lastRole = msg.role;
}
return params;
@ -512,11 +562,17 @@ function detectCompatFromUrl(baseUrl: string): Required<OpenAICompat> {
const isGrok = baseUrl.includes("api.x.ai");
const isMistral = baseUrl.includes("mistral.ai");
return {
supportsStore: !isNonStandard,
supportsDeveloperRole: !isNonStandard,
supportsReasoningEffort: !isGrok,
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
requiresToolResultName: isMistral,
requiresAssistantAfterToolResult: isMistral,
requiresThinkingAsText: isMistral,
requiresMistralToolIds: isMistral,
};
}
@ -533,5 +589,10 @@ function getCompat(model: Model<"openai-completions">): Required<OpenAICompat> {
supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort,
maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField,
requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName,
requiresAssistantAfterToolResult:
model.compat.requiresAssistantAfterToolResult ?? detected.requiresAssistantAfterToolResult,
requiresThinkingAsText: model.compat.requiresThinkingAsText ?? detected.requiresThinkingAsText,
requiresMistralToolIds: model.compat.requiresMistralToolIds ?? detected.requiresMistralToolIds,
};
}

View file

@ -39,6 +39,7 @@ export function getApiKey(provider: any): string | undefined {
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
};
const envVar = envMap[provider];

View file

@ -26,7 +26,16 @@ const _exhaustive: _CheckExhaustive = true;
// Helper type to get options for a specific API
export type OptionsForApi<TApi extends Api> = ApiOptionsMap[TApi];
export type KnownProvider = "anthropic" | "google" | "openai" | "xai" | "groq" | "cerebras" | "openrouter" | "zai";
export type KnownProvider =
| "anthropic"
| "google"
| "openai"
| "xai"
| "groq"
| "cerebras"
| "openrouter"
| "zai"
| "mistral";
export type Provider = KnownProvider | string;
export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
@ -165,6 +174,14 @@ export interface OpenAICompat {
supportsReasoningEffort?: boolean;
/** Which field to use for max tokens. Default: auto-detected from URL. */
maxTokensField?: "max_completion_tokens" | "max_tokens";
/** Whether tool results require the `name` field. Default: auto-detected from URL. */
requiresToolResultName?: boolean;
/** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */
requiresAssistantAfterToolResult?: boolean;
/** Whether thinking blocks must be converted to text blocks with <thinking> delimiters. Default: auto-detected from URL. */
requiresThinkingAsText?: boolean;
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
requiresMistralToolIds?: boolean;
}
// Model interface for the unified model system

View file

@ -17,6 +17,7 @@ import type { AssistantMessage } from "../types.js";
* - llama.cpp: "the request exceeds the available context size, try increasing it"
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
* - Cerebras: Returns "400 status code (no body)" - handled separately below
* - Mistral: Returns "400 status code (no body)" - handled separately below
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
* - Ollama: Silently truncates input - not detectable via error message
*/
@ -52,6 +53,7 @@ const OVERFLOW_PATTERNS = [
* - xAI (Grok): "maximum prompt length is X but request contains Y"
* - Groq: "reduce the length of the messages"
* - Cerebras: 400/413 status code (no body)
* - Mistral: 400/413 status code (no body)
* - OpenRouter (all backends): "maximum context length is X tokens"
* - llama.cpp: "exceeds the available context size"
* - LM Studio: "greater than the context length"
@ -85,7 +87,7 @@ export function isContextOverflow(message: AssistantMessage, contextWindow?: num
return true;
}
// Cerebras returns 400/413 with no body - check for status code pattern
// Cerebras and Mistral return 400/413 with no body - check for status code pattern
if (/^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage)) {
return true;
}

View file

@ -105,4 +105,16 @@ describe("AI Providers Abort Tests", () => {
await testImmediateAbort(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Abort", () => {
const llm = getModel("mistral", "devstral-medium-latest");
it("should abort mid-stream", async () => {
await testAbortSignal(llm);
});
it("should handle immediate abort", async () => {
await testImmediateAbort(llm);
});
});
});

View file

@ -358,6 +358,20 @@ describe("Agent Calculator Tests", () => {
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
}, 30000);
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Agent", () => {
const model = getModel("mistral", "devstral-medium-latest");
it("should calculate multiple expressions and sum the results", async () => {
const result = await calculateTest(model);
expect(result.toolCallCount).toBeGreaterThanOrEqual(2);
}, 30000);
it("should handle abort during tool execution", async () => {
const result = await abortTest(model);
expect(result.toolCallCount).toBeGreaterThanOrEqual(1);
}, 30000);
});
});
describe("agentLoopContinue", () => {

View file

@ -124,7 +124,7 @@ describe("Context overflow error handling", () => {
logResult(result);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toMatch(/exceeds the context window/i);
expect(result.errorMessage).toMatch(/maximum context length/i);
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
@ -237,6 +237,22 @@ describe("Context overflow error handling", () => {
}, 120000);
});
// =============================================================================
// Mistral
// Expected pattern: TBD - need to test actual error message
// =============================================================================
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral", () => {
it("devstral-medium-latest - should detect overflow via isContextOverflow", async () => {
const model = getModel("mistral", "devstral-medium-latest");
const result = await testContextOverflow(model, process.env.MISTRAL_API_KEY!);
logResult(result);
expect(result.stopReason).toBe("error");
expect(isContextOverflow(result.response, model.contextWindow)).toBe(true);
}, 120000);
});
// =============================================================================
// OpenRouter - Multiple backend providers
// Expected pattern: "maximum context length is X tokens"

View file

@ -289,4 +289,24 @@ describe("AI Providers Empty Message Tests", () => {
await testEmptyAssistantMessage(llm);
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Empty Messages", () => {
const llm = getModel("mistral", "devstral-medium-latest");
it("should handle empty content array", async () => {
await testEmptyMessage(llm);
});
it("should handle empty string content", async () => {
await testEmptyStringMessage(llm);
});
it("should handle whitespace-only content", async () => {
await testWhitespaceOnlyMessage(llm);
});
it("should handle empty assistant message in conversation", async () => {
await testEmptyAssistantMessage(llm);
});
});
});

View file

@ -273,18 +273,48 @@ async function testProviderHandoff<TApi extends Api>(
sourceContext: (typeof providerContexts)[keyof typeof providerContexts],
): Promise<boolean> {
// Build conversation context
let assistantMessage: AssistantMessage = sourceContext.message;
let toolResult: ToolResultMessage | undefined | null = sourceContext.toolResult;
// If target is Mistral, convert tool call IDs to Mistral format
if (targetModel.provider === "mistral" && assistantMessage.content.some((c) => c.type === "toolCall")) {
// Clone the message to avoid mutating the original
assistantMessage = {
...assistantMessage,
content: assistantMessage.content.map((content) => {
if (content.type === "toolCall") {
// Generate a Mistral-style tool call ID (uppercase letters and numbers)
const mistralId = "T7TcP5RVB"; // Using the format we know works
return {
...content,
id: mistralId,
};
}
return content;
}),
} as AssistantMessage;
// Also update the tool result if present
if (toolResult) {
toolResult = {
...toolResult,
toolCallId: "T7TcP5RVB", // Match the tool call ID
};
}
}
const messages: Message[] = [
{
role: "user",
content: "Please do some calculations, tell me about capitals, and check the weather.",
timestamp: Date.now(),
},
sourceContext.message,
assistantMessage,
];
// Add tool result if present
if (sourceContext.toolResult) {
messages.push(sourceContext.toolResult);
if (toolResult) {
messages.push(toolResult);
}
// Ask follow-up question
@ -506,4 +536,33 @@ describe("Cross-Provider Handoff Tests", () => {
expect(successCount).toBe(totalTests);
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Handoff", () => {
const model = getModel("mistral", "devstral-medium-latest");
it("should handle contexts from all providers", async () => {
console.log("\nTesting Mistral with pre-built contexts:\n");
const contextTests = [
{ label: "Anthropic-style", context: providerContexts.anthropic, sourceModel: "claude-3-5-haiku-20241022" },
{ label: "Google-style", context: providerContexts.google, sourceModel: "gemini-2.5-flash" },
{ label: "OpenAI-Completions", context: providerContexts.openaiCompletions, sourceModel: "gpt-4o-mini" },
{ label: "OpenAI-Responses", context: providerContexts.openaiResponses, sourceModel: "gpt-5-mini" },
{ label: "Aborted", context: providerContexts.aborted, sourceModel: null },
];
let successCount = 0;
const totalTests = contextTests.length;
for (const { label, context, sourceModel } of contextTests) {
const success = await testProviderHandoff(model, label, context);
if (success) successCount++;
}
console.log(`\nMistral success rate: ${successCount}/${totalTests}\n`);
// All handoffs should succeed
expect(successCount).toBe(totalTests);
}, 60000);
});
});

View file

@ -261,4 +261,16 @@ describe("Tool Results with Images", () => {
await handleToolWithTextAndImageResult(llm);
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider (pixtral-12b)", () => {
const llm = getModel("mistral", "pixtral-12b");
it("should handle tool result with only image", async () => {
await handleToolWithImageResult(llm);
});
it("should handle tool result with text and image", async () => {
await handleToolWithTextAndImageResult(llm);
});
});
});

View file

@ -0,0 +1,423 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { complete } from "../src/stream.js";
import type { Context, Tool } from "../src/types.js";
const weatherSchema = Type.Object({
location: Type.String({ description: "City name" }),
});
const weatherTool: Tool<typeof weatherSchema> = {
name: "get_weather",
description: "Get weather",
parameters: weatherSchema,
};
const testToolSchema = Type.Object({});
const testTool: Tool<typeof testToolSchema> = {
name: "test_tool",
description: "A test tool",
parameters: testToolSchema,
};
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Debug", () => {
const model = getModel("openai", "gpt-4o-mini");
it("tool call + result + follow-up user", async () => {
const context: Context = {
messages: [
{ role: "user", content: "Check weather", timestamp: Date.now() },
{
role: "assistant",
api: "openai-completions",
content: [
{ type: "toolCall", id: "call_abc123", name: "get_weather", arguments: { location: "Tokyo" } },
],
provider: "openai",
model: "gpt-4o-mini",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "call_abc123",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Tokyo: 18°C" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "What was the temperature?", timestamp: Date.now() },
],
tools: [weatherTool],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Debug", () => {
const model = getModel("mistral", "devstral-medium-latest");
it("5d. two tool calls + results, no follow-up user", async () => {
const context: Context = {
messages: [
{ role: "user", content: "Check weather in Tokyo and Paris", timestamp: Date.now() },
{
role: "assistant",
api: "openai-completions",
content: [
{ type: "toolCall", id: "T7TcP5RVB", name: "get_weather", arguments: { location: "Tokyo" } },
{ type: "toolCall", id: "X8UdQ6SWC", name: "get_weather", arguments: { location: "Paris" } },
],
provider: "mistral",
model: "devstral-medium-latest",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "T7TcP5RVB",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Tokyo: 18°C" }],
isError: false,
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "X8UdQ6SWC",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Paris: 22°C" }],
isError: false,
timestamp: Date.now(),
},
],
tools: [weatherTool],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
it("5e. two tool calls + results + user follow-up", async () => {
const context: Context = {
messages: [
{ role: "user", content: "Check weather in Tokyo and Paris", timestamp: Date.now() },
{
role: "assistant",
api: "openai-completions",
content: [
{ type: "toolCall", id: "T7TcP5RVB", name: "get_weather", arguments: { location: "Tokyo" } },
{ type: "toolCall", id: "X8UdQ6SWC", name: "get_weather", arguments: { location: "Paris" } },
],
provider: "mistral",
model: "devstral-medium-latest",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "T7TcP5RVB",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Tokyo: 18°C" }],
isError: false,
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "X8UdQ6SWC",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Paris: 22°C" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "Which is warmer?", timestamp: Date.now() },
],
tools: [weatherTool],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
it("5f. workaround: convert tool results to assistant text before user follow-up", async () => {
// Mistral doesn't allow user after tool_result
// Workaround: merge tool results into an assistant message
const context: Context = {
messages: [
{ role: "user", content: "Check weather in Tokyo and Paris", timestamp: Date.now() },
{
role: "assistant",
api: "openai-completions",
content: [
{ type: "toolCall", id: "T7TcP5RVB", name: "get_weather", arguments: { location: "Tokyo" } },
{ type: "toolCall", id: "X8UdQ6SWC", name: "get_weather", arguments: { location: "Paris" } },
],
provider: "mistral",
model: "devstral-medium-latest",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "T7TcP5RVB",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Tokyo: 18°C" }],
isError: false,
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "X8UdQ6SWC",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Paris: 22°C" }],
isError: false,
timestamp: Date.now(),
},
// Add an assistant message BEFORE the user follow-up
{
role: "assistant",
api: "openai-completions",
content: [{ type: "text", text: "I found the weather for both cities." }],
provider: "mistral",
model: "devstral-medium-latest",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
{ role: "user", content: "Which is warmer?", timestamp: Date.now() },
],
tools: [weatherTool],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
it("5h. emoji in tool result", async () => {
const context: Context = {
messages: [
{ role: "user", content: "Use the test tool", timestamp: Date.now() },
{
role: "assistant",
api: "openai-completions",
content: [{ type: "toolCall", id: "test_1", name: "test_tool", arguments: {} }],
provider: "mistral",
model: "devstral-medium-latest",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "test_1",
toolName: "test_tool",
content: [{ type: "text", text: "Result without emoji: hello world" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "What did the tool return?", timestamp: Date.now() },
],
tools: [weatherTool],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
it("5g. thinking block from another provider", async () => {
const context: Context = {
messages: [
{ role: "user", content: "What is 2+2?", timestamp: Date.now() },
{
role: "assistant",
api: "anthropic-messages",
content: [
{ type: "thinking", thinking: "Let me calculate 2+2. That equals 4.", thinkingSignature: "sig_abc" },
{ type: "text", text: "The answer is 4." },
],
provider: "anthropic",
model: "claude-3-5-haiku",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
{ role: "user", content: "What about 3+3?", timestamp: Date.now() },
],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
it("5a. tool call + result, no follow-up user message", async () => {
const context: Context = {
messages: [
{ role: "user", content: "Check weather in Tokyo", timestamp: Date.now() },
{
role: "assistant",
api: "openai-completions",
content: [{ type: "toolCall", id: "T7TcP5RVB", name: "get_weather", arguments: { location: "Tokyo" } }],
provider: "mistral",
model: "devstral-medium-latest",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "T7TcP5RVB",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Tokyo: 18°C" }],
isError: false,
timestamp: Date.now(),
},
],
tools: [weatherTool],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
it("5b. tool call + result (no text in assistant)", async () => {
const context: Context = {
messages: [
{ role: "user", content: "Check weather", timestamp: Date.now() },
{
role: "assistant",
api: "openai-completions",
content: [{ type: "toolCall", id: "T7TcP5RVB", name: "get_weather", arguments: { location: "Tokyo" } }],
provider: "mistral",
model: "devstral-medium-latest",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "T7TcP5RVB",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Tokyo: 18°C" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "What was the temperature?", timestamp: Date.now() },
],
tools: [weatherTool],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
it("5c. tool call + result (WITH text in assistant)", async () => {
const context: Context = {
messages: [
{ role: "user", content: "Check weather", timestamp: Date.now() },
{
role: "assistant",
api: "openai-completions",
content: [
{ type: "text", text: "Let me check the weather." },
{ type: "toolCall", id: "T7TcP5RVB", name: "get_weather", arguments: { location: "Tokyo" } },
],
provider: "mistral",
model: "devstral-medium-latest",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
},
{
role: "toolResult",
toolCallId: "T7TcP5RVB",
toolName: "get_weather",
content: [{ type: "text", text: "Weather in Tokyo: 18°C" }],
isError: false,
timestamp: Date.now(),
},
{ role: "user", content: "What was the temperature?", timestamp: Date.now() },
],
tools: [weatherTool],
};
const response = await complete(model, context);
console.log("Response:", response.stopReason, response.errorMessage);
expect(response.stopReason).not.toBe("error");
});
});

View file

@ -0,0 +1,215 @@
import { Mistral } from "@mistralai/mistralai";
import { describe, expect, it } from "vitest";
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral SDK Direct", () => {
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
it("tool call + result + user follow-up", async () => {
const response = await client.chat.complete({
model: "devstral-medium-latest",
messages: [
{ role: "user", content: "Check the weather" },
{
role: "assistant",
content: "",
toolCalls: [
{
id: "T7TcP5RVB",
type: "function",
function: {
name: "get_weather",
arguments: JSON.stringify({ location: "Tokyo" }),
},
},
],
},
{
role: "tool",
name: "get_weather",
content: "Weather in Tokyo: 18°C",
toolCallId: "T7TcP5RVB",
},
{ role: "user", content: "What was the temperature?" },
],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get weather for a location",
parameters: {
type: "object",
properties: {
location: { type: "string" },
},
},
},
},
],
});
console.log("Response:", JSON.stringify(response, null, 2));
expect(response.choices?.[0]?.finishReason).not.toBe("error");
});
it("emoji in tool result (no user follow-up)", async () => {
const response = await client.chat.complete({
model: "devstral-medium-latest",
messages: [
{ role: "user", content: "Use the test tool" },
{
role: "assistant",
content: "",
toolCalls: [
{
id: "T7TcP5RVB",
type: "function",
function: {
name: "test_tool",
arguments: "{}",
},
},
],
},
{
role: "tool",
name: "test_tool",
content: `Test with emoji 🙈 and other characters:
- Monkey emoji: 🙈
- Thumbs up: 👍
- Heart:
- Thinking face: 🤔
- Rocket: 🚀
- Mixed text: Mario Zechner wann? Wo? Bin grad äußersr eventuninformiert 🙈
- Japanese: こんにちは
- Chinese: 你好
- Mathematical symbols:
- Special quotes: "curly" 'quotes'`,
toolCallId: "T7TcP5RVB",
},
],
tools: [
{
type: "function",
function: {
name: "test_tool",
description: "A test tool",
parameters: {
type: "object",
properties: {},
},
},
},
],
});
console.log("Response:", JSON.stringify(response, null, 2));
// Model might make another tool call or stop - either is fine, we're testing emoji handling
expect(response.choices?.[0]?.finishReason).toMatch(/stop|tool_calls/);
});
it("emoji in tool result WITH assistant bridge + user follow-up", async () => {
const response = await client.chat.complete({
model: "devstral-medium-latest",
messages: [
{ role: "user", content: "Use the test tool" },
{
role: "assistant",
content: "",
toolCalls: [
{
id: "T7TcP5RVB",
type: "function",
function: {
name: "test_tool",
arguments: "{}",
},
},
],
},
{
role: "tool",
name: "test_tool",
content: "Result with emoji: 🙈👍❤️",
toolCallId: "T7TcP5RVB",
},
{ role: "assistant", content: "I have processed the tool results." },
{ role: "user", content: "Summarize the tool result" },
],
tools: [
{
type: "function",
function: {
name: "test_tool",
description: "A test tool",
parameters: {
type: "object",
properties: {},
},
},
},
],
});
console.log("Response:", JSON.stringify(response, null, 2));
expect(response.choices?.[0]?.finishReason).toMatch(/stop|tool_calls/);
});
it("exact payload from unicode test", async () => {
const response = await client.chat.complete({
model: "devstral-medium-latest",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Use the test tool" },
{
role: "assistant",
content: "",
toolCalls: [
{
id: "test1",
type: "function",
function: {
name: "test_tool",
arguments: "{}",
},
},
],
},
{
role: "tool",
name: "test_tool",
content: `Test with emoji 🙈 and other characters:
- Monkey emoji: 🙈
- Thumbs up: 👍
- Heart:
- Thinking face: 🤔
- Rocket: 🚀
- Mixed text: Mario Zechner wann? Wo? Bin grad äußersr eventuninformiert 🙈
- Japanese: こんにちは
- Chinese: 你好
- Mathematical symbols:
- Special quotes: "curly" 'quotes'`,
toolCallId: "test1",
},
{ role: "assistant", content: "I have processed the tool results." },
{ role: "user", content: "Summarize the tool result briefly." },
],
tools: [
{
type: "function",
function: {
name: "test_tool",
description: "A test tool",
parameters: {
type: "object",
properties: {},
},
},
},
],
});
console.log("Response:", JSON.stringify(response, null, 2));
expect(response.choices?.[0]?.finishReason).toMatch(/stop|tool_calls/);
});
});

View file

@ -629,6 +629,55 @@ describe("Generate E2E Tests", () => {
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)(
"Mistral Provider (devstral-medium-latest via OpenAI Completions)",
() => {
const llm = getModel("mistral", "devstral-medium-latest");
it("should complete basic text generation", async () => {
await basicTextGeneration(llm);
});
it("should handle tool calling", async () => {
await handleToolCall(llm);
});
it("should handle streaming", async () => {
await handleStreaming(llm);
});
it("should handle thinking mode", async () => {
// FIXME Skip for now, getting a 422 stauts code, need to test with official SDK
// const llm = getModel("mistral", "magistral-medium-latest");
// await handleThinking(llm, { reasoningEffort: "medium" });
});
it("should handle multi-turn with thinking and tools", async () => {
await multiTurn(llm, { reasoningEffort: "medium" });
});
},
);
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider (pixtral-12b with image support)", () => {
const llm = getModel("mistral", "pixtral-12b");
it("should complete basic text generation", async () => {
await basicTextGeneration(llm);
});
it("should handle tool calling", async () => {
await handleToolCall(llm);
});
it("should handle streaming", async () => {
await handleStreaming(llm);
});
it("should handle image input", async () => {
await handleImage(llm);
});
});
// Check if ollama is installed
let ollamaInstalled = false;
try {

View file

@ -77,4 +77,12 @@ describe("Token Statistics on Abort", () => {
await testTokensOnAbort(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
}, 10000);
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider", () => {
const llm = getModel("mistral", "devstral-medium-latest");
it("should include token stats when aborted mid-stream", async () => {
await testTokensOnAbort(llm);
}, 10000);
});
});

View file

@ -17,68 +17,80 @@ const calculateTool: Tool = {
parameters: calculateSchema,
};
async function testToolCallWithoutResult(model: any, options: any = {}) {
// Step 1: Create context with the calculate tool
const context: Context = {
systemPrompt: "You are a helpful assistant. Use the calculate tool when asked to perform calculations.",
messages: [],
tools: [calculateTool],
};
// Step 2: Ask the LLM to make a tool call
context.messages.push({
role: "user",
content: "Please calculate 25 * 18 using the calculate tool.",
timestamp: Date.now(),
});
// Step 3: Get the assistant's response (should contain a tool call)
const firstResponse = await complete(model, context, options);
context.messages.push(firstResponse);
console.log("First response:", JSON.stringify(firstResponse, null, 2));
// Verify the response contains a tool call
const hasToolCall = firstResponse.content.some((block) => block.type === "toolCall");
expect(hasToolCall).toBe(true);
if (!hasToolCall) {
throw new Error("Expected assistant to make a tool call, but none was found");
}
// Step 4: Send a user message WITHOUT providing tool result
// This simulates the scenario where a tool call was aborted/cancelled
context.messages.push({
role: "user",
content: "Never mind, just tell me what is 2+2?",
timestamp: Date.now(),
});
// Step 5: The fix should filter out the orphaned tool call, and the request should succeed
const secondResponse = await complete(model, context, options);
console.log("Second response:", JSON.stringify(secondResponse, null, 2));
// The request should succeed (not error) - that's the main thing we're testing
expect(secondResponse.stopReason).not.toBe("error");
// Should have some content in the response
expect(secondResponse.content.length).toBeGreaterThan(0);
// The LLM may choose to answer directly or make a new tool call - either is fine
// The important thing is it didn't fail with the orphaned tool call error
const textContent = secondResponse.content
.filter((block) => block.type === "text")
.map((block) => (block.type === "text" ? block.text : ""))
.join(" ");
expect(textContent.length).toBeGreaterThan(0);
console.log("Answer:", textContent);
// Verify the stop reason is either "stop" or "toolUse" (new tool call)
expect(["stop", "toolUse"]).toContain(secondResponse.stopReason);
}
describe("Tool Call Without Result Tests", () => {
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider - Missing Tool Result", () => {
const model = getModel("anthropic", "claude-3-5-haiku-20241022");
it("should filter out tool calls without corresponding tool results", async () => {
// Step 1: Create context with the calculate tool
const context: Context = {
systemPrompt: "You are a helpful assistant. Use the calculate tool when asked to perform calculations.",
messages: [],
tools: [calculateTool],
};
await testToolCallWithoutResult(model);
}, 30000);
});
// Step 2: Ask the LLM to make a tool call
context.messages.push({
role: "user",
content: "Please calculate 25 * 18 using the calculate tool.",
timestamp: Date.now(),
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider - Missing Tool Result", () => {
const model = getModel("mistral", "devstral-medium-latest");
// Step 3: Get the assistant's response (should contain a tool call)
const firstResponse = await complete(model, context);
context.messages.push(firstResponse);
console.log("First response:", JSON.stringify(firstResponse, null, 2));
// Verify the response contains a tool call
const hasToolCall = firstResponse.content.some((block) => block.type === "toolCall");
expect(hasToolCall).toBe(true);
if (!hasToolCall) {
throw new Error("Expected assistant to make a tool call, but none was found");
}
// Step 4: Send a user message WITHOUT providing tool result
// This simulates the scenario where a tool call was aborted/cancelled
context.messages.push({
role: "user",
content: "Never mind, just tell me what is 2+2?",
timestamp: Date.now(),
});
// Step 5: The fix should filter out the orphaned tool call, and the request should succeed
const secondResponse = await complete(model, context);
console.log("Second response:", JSON.stringify(secondResponse, null, 2));
// The request should succeed (not error) - that's the main thing we're testing
expect(secondResponse.stopReason).not.toBe("error");
// Should have some content in the response
expect(secondResponse.content.length).toBeGreaterThan(0);
// The LLM may choose to answer directly or make a new tool call - either is fine
// The important thing is it didn't fail with the orphaned tool call error
const textContent = secondResponse.content
.filter((block) => block.type === "text")
.map((block) => (block.type === "text" ? block.text : ""))
.join(" ");
expect(textContent.length).toBeGreaterThan(0);
console.log("Answer:", textContent);
// Verify the stop reason is either "stop" or "toolUse" (new tool call)
expect(["stop", "toolUse"]).toContain(secondResponse.stopReason);
it("should filter out tool calls without corresponding tool results", async () => {
await testToolCallWithoutResult(model);
}, 30000);
});
});

View file

@ -258,6 +258,25 @@ describe("totalTokens field", () => {
}, 60000);
});
// =========================================================================
// Mistral
// =========================================================================
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral", () => {
it("devstral-medium-latest - should return totalTokens equal to sum of components", async () => {
const llm = getModel("mistral", "devstral-medium-latest");
console.log(`\nMistral / ${llm.id}:`);
const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.MISTRAL_API_KEY });
logUsage("First request", first);
logUsage("Second request", second);
assertTotalTokensEqualsComponents(first);
assertTotalTokensEqualsComponents(second);
}, 60000);
});
// =========================================================================
// OpenRouter - Multiple backend providers
// =========================================================================

View file

@ -389,4 +389,20 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
await testUnpairedHighSurrogate(llm);
});
});
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Unicode Handling", () => {
const llm = getModel("mistral", "devstral-medium-latest");
it("should handle emoji in tool results", async () => {
await testEmojiInToolResults(llm);
});
it("should handle real-world LinkedIn comment data with emoji", async () => {
await testRealWorldLinkedInData(llm);
});
it("should handle unpaired high surrogate (0xD83D) in tool results", async () => {
await testUnpairedHighSurrogate(llm);
});
});
});

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- **Mistral provider**: Added support for Mistral AI models. Set `MISTRAL_API_KEY` environment variable to use.
## [0.18.0] - 2025-12-10
### Added

View file

@ -108,6 +108,7 @@ Set the environment variable for your provider:
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
| OpenAI | `OPENAI_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Mistral | `MISTRAL_API_KEY` |
| Groq | `GROQ_API_KEY` |
| Cerebras | `CEREBRAS_API_KEY` |
| xAI | `XAI_API_KEY` |
@ -542,7 +543,7 @@ pi [options] [@files...] [messages...]
| Option | Description |
|--------|-------------|
| `--provider <name>` | Provider: `anthropic`, `openai`, `google`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, or custom |
| `--provider <name>` | Provider: `anthropic`, `openai`, `google`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, or custom |
| `--model <id>` | Model ID |
| `--api-key <key>` | API key (overrides environment) |
| `--system-prompt <text\|file>` | Custom system prompt (text or file path) |

View file

@ -0,0 +1,255 @@
# Gemini OAuth Integration Guide
This document provides a comprehensive analysis of how OAuth authentication could be implemented for Google Gemini in the pi coding-agent, based on the existing Anthropic OAuth implementation and the Gemini CLI's approach.
## Table of Contents
1. [Current Anthropic OAuth Implementation](#current-anthropic-oauth-implementation)
2. [Gemini CLI Authentication Analysis](#gemini-cli-authentication-analysis)
3. [Gemini API Capabilities](#gemini-api-capabilities)
4. [Gemini API Endpoints](#gemini-api-endpoints)
5. [Implementation Plan](#implementation-plan)
## Current Anthropic OAuth Implementation
The pi coding-agent implements OAuth for Anthropic with the following architecture:
### Key Components
1. **OAuth Flow** (`packages/coding-agent/src/core/oauth/anthropic.ts`):
- Uses PKCE (Proof Key for Code Exchange) flow for security
- Client ID: `9d1c250a-e61b-44d9-88ed-5944d1962f5e`
- Authorization URL: `https://claude.ai/oauth/authorize`
- Token URL: `https://console.anthropic.com/v1/oauth/token`
- Scopes: `org:create_api_key user:profile user:inference`
2. **Token Storage** (`packages/coding-agent/src/core/oauth/storage.ts`):
- Stores credentials in `~/.pi/agent/oauth.json`
- File permissions set to 0600 (owner read/write only)
- Format: `{ provider: { type: "oauth", refresh: string, access: string, expires: number } }`
3. **Token Management** (`packages/coding-agent/src/core/oauth/index.ts`):
- Auto-refresh tokens when expired (with 5-minute buffer)
- Supports multiple providers through `SupportedOAuthProvider` type
- Provider info includes id, name, and availability status
4. **Model Integration** (`packages/coding-agent/src/core/model-config.ts`):
- Checks OAuth tokens first, then environment variables
- OAuth status cached to avoid repeated file reads
- Maps providers to OAuth providers via `providerToOAuthProvider`
### Authentication Flow
1. User initiates login with `pi auth login`
2. Authorization URL is generated with PKCE challenge
3. User opens URL in browser and authorizes
4. User copies authorization code (format: `code#state`)
5. Code is exchanged for access/refresh tokens
6. Tokens are saved encrypted with expiry time
## Gemini CLI Authentication Analysis
The Gemini CLI uses a more complex OAuth implementation with several key differences:
### Authentication Methods
Gemini supports multiple authentication types:
- `LOGIN_WITH_GOOGLE` (OAuth personal account)
- `USE_GEMINI` (API key)
- `USE_VERTEX_AI` (Vertex AI)
- `COMPUTE_ADC` (Application Default Credentials)
### OAuth Implementation Details
1. **OAuth Configuration**:
- Client ID and Secret: See [google-gemini/gemini-cli oauth2.ts](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts) (public for installed apps per Google's OAuth docs)
- Scopes:
- `https://www.googleapis.com/auth/cloud-platform`
- `https://www.googleapis.com/auth/userinfo.email`
- `https://www.googleapis.com/auth/userinfo.profile`
2. **Authentication Flows**:
- **Web Flow**: Opens browser, runs local HTTP server for callback
- **User Code Flow**: For environments without browser (NO_BROWSER=true)
- Uses Google's `google-auth-library` for OAuth handling
3. **Token Storage**:
- Supports encrypted storage via `OAuthCredentialStorage`
- Falls back to plain JSON storage
- Stores user info (email) separately
4. **API Integration**:
- Uses `CodeAssistServer` for API calls
- Endpoint: `https://cloudcode-pa.googleapis.com`
- Includes user tier information (FREE, STANDARD, etc.)
## Gemini API Capabilities
Based on the Gemini CLI analysis:
### System Prompts
✅ **Yes, Gemini supports system prompts**
- Implemented via `getCoreSystemPrompt()` in the codebase
- System instructions are part of the `GenerateContentParameters`
### Tools/Function Calling
✅ **Yes, Gemini supports tools and function calling**
- Uses the `Tool` type from `@google/genai`
- Extensive tool support including:
- File system operations (read, write, edit)
- Web search and fetch
- MCP (Model Context Protocol) tools
- Custom tool registration
### Content Generation
- Supports streaming and non-streaming generation
- Token counting capabilities
- Embedding support
- Context compression for long conversations
## Gemini API Endpoints
When using OAuth tokens, the Gemini CLI talks to:
### Primary Endpoint
- **Base URL**: `https://cloudcode-pa.googleapis.com`
- **API Version**: `v1internal`
### Key Methods
- `generateContent` - Non-streaming content generation
- `streamGenerateContent` - Streaming content generation
- `countTokens` - Token counting
- `embedContent` - Text embeddings
- `loadCodeAssist` - User setup and tier information
- `onboardUser` - User onboarding
### Authentication
- OAuth tokens are passed via `AuthClient` from `google-auth-library`
- Tokens are automatically refreshed by the library
- Project ID and session ID included in requests
## Implementation Plan
### 1. Add Gemini OAuth Provider Support
**File**: `packages/coding-agent/src/core/oauth/gemini.ts`
```typescript
import { OAuth2Client } from 'google-auth-library';
import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
// OAuth credentials from google-gemini/gemini-cli:
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/code_assist/oauth2.ts
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile"
];
export async function loginGemini(
onAuthUrl: (url: string) => void,
onPromptCode: () => Promise<string>,
): Promise<void> {
// Implementation similar to Anthropic but using google-auth-library
}
export async function refreshGeminiToken(refreshToken: string): Promise<OAuthCredentials> {
// Use google-auth-library for refresh
}
```
### 2. Update OAuth Index
**File**: `packages/coding-agent/src/core/oauth/index.ts`
```typescript
export type SupportedOAuthProvider = "anthropic" | "github-copilot" | "gemini";
// Add Gemini to provider list
{
id: "gemini",
name: "Google Gemini (Code Assist)",
available: true,
}
// Add cases for Gemini in login/refresh functions
```
### 3. Create Gemini API Client
**File**: `packages/ai/src/providers/gemini-oauth.ts`
```typescript
export class GeminiOAuthProvider implements Provider {
// Implement Provider interface
// Use CodeAssistServer approach from Gemini CLI
// Map to standard pi-ai API format
}
```
### 4. Update Model Configuration
**File**: `packages/coding-agent/src/core/model-config.ts`
```typescript
// Add to providerToOAuthProvider mapping
gemini: "gemini",
// Add Gemini OAuth token check
if (model.provider === "gemini") {
const oauthToken = await getOAuthToken("gemini");
if (oauthToken) return oauthToken;
const oauthEnv = process.env.GEMINI_OAUTH_TOKEN;
if (oauthEnv) return oauthEnv;
}
```
### 5. Dependencies
Add to `package.json`:
```json
{
"dependencies": {
"google-auth-library": "^9.0.0"
}
}
```
### 6. Environment Variables
Support these environment variables:
- `GEMINI_OAUTH_TOKEN` - Manual OAuth token
- `GOOGLE_CLOUD_PROJECT` - For project-specific features
- `NO_BROWSER` - Force user code flow
### Key Differences from Anthropic Implementation
1. **Authentication Library**: Use `google-auth-library` instead of manual OAuth
2. **Multiple Auth Types**: Support OAuth, API key, and ADC
3. **User Info**: Fetch and cache user email/profile
4. **Project Context**: Include project ID in API calls
5. **Tier Management**: Handle user tier (FREE/STANDARD) responses
### Challenges and Considerations
1. **API Access**: The Code Assist API (`cloudcode-pa.googleapis.com`) might require special access or be in preview
2. **Model Naming**: Need to map Gemini model names to Code Assist equivalents
3. **Rate Limits**: Handle tier-based rate limits
4. **Error Handling**: Map Google-specific errors to pi error types
5. **Token Scopes**: Ensure scopes are sufficient for all operations
### Testing Plan
1. Test OAuth flow (browser and NO_BROWSER modes)
2. Test token refresh
3. Test API calls with OAuth tokens
4. Test fallback to API keys
5. Test error scenarios (invalid tokens, network errors)
6. Test model switching and tier limits
### Migration Path
1. Users with `GEMINI_API_KEY` continue working unchanged
2. New `pi auth login gemini` command for OAuth
3. OAuth takes precedence over API keys when available
4. Clear messaging about benefits (higher limits, better features)

View file

@ -11,6 +11,14 @@ Hooks are automatically discovered from two locations:
All `.ts` files in these directories are loaded automatically. Project hooks let you define project-specific behavior (similar to `.pi/AGENTS.md`).
You can also load a specific hook file directly using the `--hook` flag:
```bash
pi --hook ./my-hook.ts
```
This is useful for testing hooks without placing them in the standard directories.
### Additional Configuration
You can also add explicit hook paths in `~/.pi/agent/settings.json`:

View file

@ -19,6 +19,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
groq: "openai/gpt-oss-120b",
cerebras: "zai-glm-4.6",
zai: "glm-4.6",
mistral: "devstral-medium-latest",
};
export interface ScopedModel {

View file

@ -1,40 +0,0 @@
import type { HookAPI } from "./packages/coding-agent/src/index.js";
const dangerousPatterns = [
{ pattern: /\brm\s+(-[rf]+\s+)*\//, reason: "Deleting from root" },
{ pattern: /\brm\s+-rf?\s/, reason: "Recursive delete" },
{ pattern: /\bsudo\b/, reason: "Elevated privileges" },
{ pattern: /\bchmod\s+777\b/, reason: "World-writable permissions" },
{ pattern: /\b(mkfs|dd\s+if=)/, reason: "Disk operations" },
{ pattern: />\s*\/dev\//, reason: "Writing to device" },
{ pattern: /\bcurl\b.*\|\s*(ba)?sh/, reason: "Pipe to shell" },
{ pattern: /\bwget\b.*\|\s*(ba)?sh/, reason: "Pipe to shell" },
];
const alwaysAllow = [
/^(ls|cat|head|tail|grep|find|pwd|echo|date|whoami)\b/,
/^git\s+(status|log|diff|branch|show)\b/,
/^npm\s+(run|test|install|ci)\b/,
];
export default function (pi: HookAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const cmd = (event.input.command as string).trim();
// Always allow safe commands
if (alwaysAllow.some((p) => p.test(cmd))) return;
// Check for dangerous patterns
for (const { pattern, reason } of dangerousPatterns) {
if (pattern.test(cmd)) {
const ok = await ctx.ui.confirm(`⚠️ ${reason}`, cmd);
if (!ok) return { block: true, reason: `Blocked: ${reason}` };
return; // User approved
}
}
return;
});
}

View file

@ -1,35 +0,0 @@
import * as fs from "node:fs";
import type { HookAPI } from "./packages/coding-agent/src/index.js";
export default function (pi: HookAPI) {
pi.on("session_start", async (_event, ctx) => {
const result = ctx.ui.input("Session started! Type something to begin...");
ctx.ui.notify(`You entered: ${result}`, "info");
});
pi.on("tool_call", async (event, ctx) => {
console.log(`[test-hook] tool_call: ${event.toolName}`);
// Example: block dangerous bash commands
if (event.toolName === "bash") {
const cmd = event.input.command as string;
if (/rm\s+-rf/.test(cmd)) {
const ok = await ctx.ui.confirm("Dangerous command", `Allow: ${cmd}?`);
if (!ok) {
return { block: true, reason: "User blocked rm -rf" };
}
}
}
return undefined;
});
pi.on("tool_result", async (event, _ctx) => {
console.log(`[test-hook] tool_result: ${event.toolName} (${event.result.length} chars)`);
return undefined;
});
pi.on("turn_end", async (event, _ctx) => {
console.log(`[test-hook] turn_end: turn ${event.turnIndex}`);
});
}