mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 14:01:06 +00:00
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:
parent
a248e2547a
commit
99b4b1aca0
31 changed files with 1856 additions and 282 deletions
38
img-hook.ts
38
img-hook.ts
|
|
@ -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
19
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-...
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
423
packages/ai/test/mistral-debug.test.ts
Normal file
423
packages/ai/test/mistral-debug.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
215
packages/ai/test/mistral-sdk.test.ts
Normal file
215
packages/ai/test/mistral-sdk.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
255
packages/coding-agent/docs/gemini.md
Normal file
255
packages/coding-agent/docs/gemini.md
Normal 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)
|
||||
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
35
test-hook.ts
35
test-hook.ts
|
|
@ -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}`);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue