Merge branch 'main' into approvals

This commit is contained in:
Armin Ronacher
2026-06-02 17:27:38 +02:00
Unverified
28 changed files with 1252 additions and 107 deletions
+87
View File
@@ -0,0 +1,87 @@
# Security Policy
This document should guide you about understanding the security concept behind
Pi and also where the boundaries are.
In general Pi is a coding agent that runs locally within the security boundary
of the user that is running it. It's the responsibiltiy of the user to monitor
its operations or to contain it within a container, virtual machine or other
Sandbox solution.
Pi treats the local user account and files writable by that account as inside
the same trust boundary as the Pi process itself. If an attacker can modify files
under the user's home directory, workspace, shell startup files, environment, or
Pi configuration, they can generally influence Pi or other local developer tools.
Reports that depend on such prior local write access are not security
vulnerabilities unless they demonstrate how Pi grants that write access or crosses
an operating-system privilege boundary.
Pi relies on users installing trustworthy extensions and loading trustworthy
skills and only to use pi within trusted repositories. This is because files
like `AGENTS.md` or instructions in comments can be used to prompt inject the
coding agent trivially and this cannot be protected against.
## Reporting a Vulnerability
If you believe you found a security vulnerability in pi or another package in
this repository, please report it privately by either:
- Emailing `security@earendil.com`, or
- Opening a private report through GitHub Security Advisories for this repository
Please include:
- A description of the issue and its impact
- Steps to reproduce, proof of concept, or relevant logs
- Affected package, version, commit, or configuration
- Any known mitigations
Do not open a public issue for security-sensitive reports. We will review
reports and coordinate disclosure as appropriate.
## Scope
Security issues in the distributed packages, command-line tools, APIs, and
repository code are in scope as well as earendil operated infrastricture
on `pi.dev`.
## Out Of Scope
- Local code execution or sandboxing behavior (the Pi coding agent intentionally does not have a sandbox)
- Behavior of pi extensions or skills installed by the user
- Risks from working in untrusted repositories
- Risks from installing untrusted extensions, skills, packages, or tools
- Isuses caused by non trustworthy MITM proxies
- Public internet exposure of a Pi installation
- Prompt injection attacks
- Exposed secrets that are third-party/user-controlled credentials
- Reports requiring the ability to create, modify, delete, or replace files,
directories, symlinks, environment variables, shell configuration, or other
user-controlled local state on the target machine. This includes `~/.pi`,
`~/.pi/agent/models.json`, workspace files, `AGENTS.md`, skills, extensions,
extension configuration, dotfiles, and files synchronized through NFS, roaming
profiles, or dotfile managers, unless the report shows how Pi itself grants
that access.
- Issues caused by intentionally weakened user configuration.
- Resource/DOS claims that require trusted local input/config against the pi coding agent.
- Reports about malicious model output.
- User-approved or user-initiated local actions presented as vulnerabilities.
## Notes for Reporters
The most useful reports show a current, reproducible security boundary bypass
with demonstrated impact. Reports that only show expected local-agent behavior,
prompt injection, or a malicious trusted extension/skill are not security
vulnerabilities under this model.
For example, a report showing that malicious contents written to a trusted Pi
configuration file cause Pi to execute commands, load attacker-controlled tools,
send credentials to an attacker-controlled endpoint, or otherwise change behavior
is out of scope.
When possible, include the exact affected path, package version or commit SHA,
configuration, and a proof of concept against the latest release or latest
`main`. For dependency reports, include evidence that the shipped dependency is
affected and that the issue is reachable through Pi. For exposed-secret reports,
include evidence that the credential is owned by Earendil or grants access to
Earendil-operated infrastructure or services.
+7
View File
@@ -2,10 +2,17 @@
## [Unreleased]
### Added
- Added Ant Ling as a built-in OpenAI-compatible provider with Ling 2.6 and Ring 2.6 models.
- Added MiniMax-M3 model to the `minimax` and `minimax-cn` direct providers, and removed the hardcoded context-window override that was masking models.dev values ([#5313](https://github.com/earendil-works/pi/issues/5313)).
- Added NVIDIA NIM as a built-in OpenAI-compatible provider, exposing public NIM models that support tool use.
### Fixed
- Fixed Amazon Bedrock requests to replace blank required user/tool-result text with a placeholder and skip blank replay text blocks ([#4975](https://github.com/earendil-works/pi/issues/4975)).
- Fixed OpenAI GPT-5.5 generated metadata to omit unsupported minimal thinking ([#5243](https://github.com/earendil-works/pi/issues/5243)).
- Fixed OpenRouter Kimi K2.6 thinking replay and preserved developer-role instructions for OpenRouter OpenAI and Anthropic models ([#5309](https://github.com/earendil-works/pi/issues/5309)).
- Fixed GitHub Copilot and OpenRouter test model references that became stale after model regeneration.
## [0.78.0] - 2026-05-29
+7 -3
View File
@@ -51,9 +51,11 @@ Unified LLM API with automatic model discovery, provider configuration, token an
## Supported Providers
- **OpenAI**
- **Ant Ling**
- **Azure OpenAI (Responses)**
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
- **DeepSeek**
- **NVIDIA NIM**
- **Anthropic**
- **Google**
- **Vertex AI** (Gemini via Vertex AI)
@@ -801,7 +803,7 @@ A **provider** offers models through a specific API. For example:
- **Google** models use the `google-generative-ai` API
- **OpenAI** models use the `openai-responses` API
- **Mistral** models use the `mistral-conversations` API
- **xAI, Cerebras, Groq, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible)
- **xAI, Cerebras, Groq, NVIDIA NIM, Together AI, etc.** models use the `openai-completions` API (OpenAI-compatible)
### Querying Providers and Models
@@ -923,7 +925,7 @@ const ollamaReasoningModel: Model<'openai-completions'> = {
### OpenAI Compatibility Settings
The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags.
The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, NVIDIA NIM, Together AI, zAi, OpenCode, Cloudflare Workers AI, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags.
```typescript
interface OpenAICompletionsCompat {
@@ -938,7 +940,7 @@ interface OpenAICompletionsCompat {
requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false)
requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false)
requiresReasoningContentOnAssistantMessages?: boolean; // Whether all replayed assistant messages must include empty reasoning_content when reasoning is enabled (default: auto-detected for DeepSeek)
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking (default: openai)
thinkingFormat?: 'openai' | 'openrouter' | 'deepseek' | 'together' | 'zai' | 'qwen' | 'qwen-chat-template' | 'string-thinking' | 'ant-ling'; // Format for reasoning param: 'openai' uses reasoning_effort, 'openrouter' uses reasoning: { effort }, 'deepseek' uses thinking: { type } plus reasoning_effort when supported, 'together' uses reasoning: { enabled } plus reasoning_effort when supported, 'zai' uses enable_thinking, 'qwen' uses enable_thinking, 'qwen-chat-template' uses chat_template_kwargs.enable_thinking, 'string-thinking' uses top-level thinking, 'ant-ling' uses reasoning: { effort } only for mapped efforts (default: openai)
cacheControlFormat?: 'anthropic'; // Anthropic-style cache_control on system prompt, last tool, and last user/assistant text content
openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {})
vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {})
@@ -1099,9 +1101,11 @@ In Node.js environments, you can set environment variables to avoid passing API
| Provider | Environment Variable(s) |
|----------|------------------------|
| OpenAI | `OPENAI_API_KEY` |
| Ant Ling | `ANT_LING_API_KEY` |
| Azure OpenAI | `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_BASE_URL` (e.g. `https://{resource}.openai.azure.com`) or `AZURE_OPENAI_RESOURCE_NAME`. Supports `*.openai.azure.com` and `*.cognitiveservices.azure.com`; root endpoints auto-normalize to `/openai/v1`. Optional: `AZURE_OPENAI_API_VERSION` (default `v1`), `AZURE_OPENAI_DEPLOYMENT_NAME_MAP`. |
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
| DeepSeek | `DEEPSEEK_API_KEY` |
| NVIDIA NIM | `NVIDIA_API_KEY` |
| Google | `GEMINI_API_KEY` |
| Vertex AI | `GOOGLE_CLOUD_API_KEY` or `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC |
| Mistral | `MISTRAL_API_KEY` |
+165 -12
View File
@@ -32,12 +32,17 @@ interface ModelsDevModel {
};
modalities?: {
input?: string[];
output?: string[];
};
provider?: {
npm?: string;
};
}
interface NvidiaNimModelListItem {
id: string;
}
interface AiGatewayModel {
id: string;
name?: string;
@@ -117,6 +122,38 @@ const TOGETHER_TOGGLE_REASONING_LEVEL_MAP = {
const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1";
const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh";
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
const NVIDIA_HEADERS = {
"NVCF-POLL-SECONDS": "3600",
} as const;
const NVIDIA_OPENAI_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsStrictMode: false,
supportsLongCacheRetention: false,
};
const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([
"abacusai/dracarys-llama-3.1-70b-instruct",
"bytedance/seed-oss-36b-instruct",
"deepseek-ai/deepseek-v4-flash",
"deepseek-ai/deepseek-v4-pro",
"google/gemma-2-2b-it",
"google/gemma-3n-e2b-it",
"google/gemma-3n-e4b-it",
"google/gemma-4-31b-it",
"meta/llama-3.2-1b-instruct",
"meta/llama-4-maverick-17b-128e-instruct",
"microsoft/phi-4-mini-instruct",
"minimaxai/minimax-m2.7",
"mistralai/mistral-nemotron",
"nvidia/nemotron-mini-4b-instruct",
"qwen/qwen3-next-80b-a3b-instruct",
"qwen/qwen3.5-397b-a17b",
"sarvamai/sarvam-m",
"upstage/solar-10.7b-instruct",
]);
const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]);
const EAGER_TOOL_INPUT_STREAMING_UNSUPPORTED_ANTHROPIC_MODELS = new Set([
"github-copilot:claude-haiku-4.5",
@@ -132,6 +169,15 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = {
xhigh: "max",
} as const;
const ANT_LING_RING_THINKING_LEVEL_MAP = {
off: null,
minimal: null,
low: null,
medium: null,
high: "high",
xhigh: "xhigh",
} as const;
const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([
"gpt-5.1",
"gpt-5.2",
@@ -293,6 +339,10 @@ function applyThinkingLevelMetadata(model: Model<any>): void {
// OpenCode Zen Grok Build reasons by default but rejects explicit reasoningEffort.
mergeThinkingLevelMap(model, { off: null, minimal: null, low: null, medium: null });
}
if (model.provider === "ant-ling" && model.reasoning) {
// Ring reasons by default. Only high/xhigh have documented explicit effort controls.
mergeThinkingLevelMap(model, ANT_LING_RING_THINKING_LEVEL_MAP);
}
}
function getAnthropicMessagesCompat(provider: string, modelId: string): AnthropicMessagesCompat | undefined {
@@ -312,6 +362,30 @@ function getBedrockBaseUrl(modelId: string): string {
: "https://bedrock-runtime.us-east-1.amazonaws.com";
}
function normalizeNvidiaModelId(modelId: string): string {
return modelId.toLowerCase().replaceAll("_", ".");
}
async function fetchNvidiaNimModelIds(): Promise<Map<string, string>> {
try {
console.log("Fetching models from NVIDIA NIM API...");
const response = await fetch(`${NVIDIA_BASE_URL}/models`);
const data = (await response.json()) as { data?: NvidiaNimModelListItem[] };
const modelIds = new Map<string, string>();
for (const model of data.data ?? []) {
modelIds.set(model.id, model.id);
modelIds.set(normalizeNvidiaModelId(model.id), model.id);
}
console.log(`Fetched ${data.data?.length ?? 0} model IDs from NVIDIA NIM`);
return modelIds;
} catch (error) {
console.error("Failed to fetch NVIDIA NIM models:", error);
return new Map();
}
}
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
try {
console.log("Fetching models from OpenRouter API...");
@@ -435,6 +509,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
const data = await response.json();
const models: Model<any>[] = [];
const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map<string, string>();
// Process Amazon Bedrock models
if (data["amazon-bedrock"]?.models) {
@@ -836,6 +911,40 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
}
}
// Process NVIDIA NIM models
if (data.nvidia?.models) {
for (const [modelId, model] of Object.entries(data.nvidia.models)) {
const m = model as ModelsDevModel;
if (m.tool_call !== true) continue;
if (!m.modalities?.input?.includes("text")) continue;
if (!m.modalities?.output?.includes("text")) continue;
const liveModelId = nvidiaNimModelIds.get(modelId) ?? nvidiaNimModelIds.get(normalizeNvidiaModelId(modelId));
if (!liveModelId) continue;
if (NVIDIA_NIM_UNSUPPORTED_MODELS.has(liveModelId)) continue;
models.push({
id: liveModelId,
name: m.name || liveModelId,
api: "openai-completions",
provider: "nvidia",
baseUrl: NVIDIA_BASE_URL,
headers: { ...NVIDIA_HEADERS },
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,
},
compat: NVIDIA_OPENAI_COMPAT,
contextWindow: m.limit?.context || 4096,
maxTokens: m.limit?.output || 4096,
});
}
}
// Process Together AI models
const togetherProvider = data.together ?? data.togetherai ?? data["together-ai"];
if (togetherProvider?.models) {
@@ -1247,7 +1356,11 @@ async function generateModels() {
candidate.maxTokens = 4096;
}
if (candidate.provider === "openrouter" && candidate.id.startsWith("moonshotai/kimi-k2.6")) {
candidate.compat = { ...candidate.compat, supportsDeveloperRole: false };
candidate.compat = {
...candidate.compat,
supportsDeveloperRole: false,
requiresReasoningContentOnAssistantMessages: true,
};
}
if (candidate.provider === "openrouter" && candidate.id === "z-ai/glm-5") {
candidate.cost.input = 0.6;
@@ -1543,6 +1656,56 @@ async function generateModels() {
];
allModels.push(...deepseekV4Models);
const antLingCompat: OpenAICompletionsCompat = {
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
supportsLongCacheRetention: false,
};
const antLingModels: Model<"openai-completions">[] = [
{
id: "Ling-2.6-flash",
name: "Ling 2.6 Flash",
api: "openai-completions",
baseUrl: "https://api.ant-ling.com/v1",
provider: "ant-ling",
reasoning: false,
input: ["text"],
cost: { input: 0.01, output: 0.02, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: antLingCompat,
},
{
id: "Ling-2.6-1T",
name: "Ling 2.6 1T",
api: "openai-completions",
baseUrl: "https://api.ant-ling.com/v1",
provider: "ant-ling",
reasoning: false,
input: ["text"],
cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: antLingCompat,
},
{
id: "Ring-2.6-1T",
name: "Ring 2.6 1T",
api: "openai-completions",
baseUrl: "https://api.ant-ling.com/v1",
provider: "ant-ling",
reasoning: true,
input: ["text"],
cost: { input: 0.06, output: 0.25, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: { ...antLingCompat, thinkingFormat: "ant-ling" },
},
];
allModels.push(...antLingModels);
for (const candidate of allModels) {
if (candidate.api === "openai-completions" && candidate.id.includes("deepseek-v4")) {
candidate.compat = {
@@ -1557,17 +1720,7 @@ async function generateModels() {
}
}
const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed"]);
for (const candidate of allModels) {
if (
(candidate.provider === "minimax" || candidate.provider === "minimax-cn") &&
minimaxDirectSupportedIds.has(candidate.id)
) {
candidate.contextWindow = 204800;
candidate.maxTokens = 131072;
}
}
const minimaxDirectSupportedIds = new Set(["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M3"]);
for (let i = allModels.length - 1; i >= 0; i--) {
const candidate = allModels[i];
+2
View File
@@ -99,8 +99,10 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
}
const envMap: Record<string, string> = {
"ant-ling": "ANT_LING_API_KEY",
openai: "OPENAI_API_KEY",
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
nvidia: "NVIDIA_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
google: "GEMINI_API_KEY",
"google-vertex": "GOOGLE_CLOUD_API_KEY",
+478 -5
View File
@@ -1552,6 +1552,63 @@ export const MODELS = {
maxTokens: 101376,
} satisfies Model<"bedrock-converse-stream">,
},
"ant-ling": {
"Ling-2.6-1T": {
id: "Ling-2.6-1T",
name: "Ling 2.6 1T",
api: "openai-completions",
provider: "ant-ling",
baseUrl: "https://api.ant-ling.com/v1",
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false},
reasoning: false,
input: ["text"],
cost: {
input: 0.06,
output: 0.25,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"Ling-2.6-flash": {
id: "Ling-2.6-flash",
name: "Ling 2.6 Flash",
api: "openai-completions",
provider: "ant-ling",
baseUrl: "https://api.ant-ling.com/v1",
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false},
reasoning: false,
input: ["text"],
cost: {
input: 0.01,
output: 0.02,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"Ring-2.6-1T": {
id: "Ring-2.6-1T",
name: "Ring 2.6 1T",
api: "openai-completions",
provider: "ant-ling",
baseUrl: "https://api.ant-ling.com/v1",
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsLongCacheRetention":false,"thinkingFormat":"ant-ling"},
reasoning: true,
thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh"},
input: ["text"],
cost: {
input: 0.06,
output: 0.25,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
},
"anthropic": {
"claude-3-5-haiku-20241022": {
id: "claude-3-5-haiku-20241022",
@@ -5564,6 +5621,23 @@ export const MODELS = {
contextWindow: 204800,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
"MiniMax-M3": {
id: "MiniMax-M3",
name: "MiniMax-M3",
api: "anthropic-messages",
provider: "minimax",
baseUrl: "https://api.minimax.io/anthropic",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.3,
output: 1.2,
cacheRead: 0.06,
cacheWrite: 0.375,
},
contextWindow: 512000,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
},
"minimax-cn": {
"MiniMax-M2.7": {
@@ -5600,6 +5674,23 @@ export const MODELS = {
contextWindow: 204800,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
"MiniMax-M3": {
id: "MiniMax-M3",
name: "MiniMax-M3",
api: "anthropic-messages",
provider: "minimax-cn",
baseUrl: "https://api.minimaxi.com/anthropic",
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.3,
output: 1.2,
cacheRead: 0.06,
cacheWrite: 0.375,
},
contextWindow: 512000,
maxTokens: 131072,
} satisfies Model<"anthropic-messages">,
},
"mistral": {
"codestral-latest": {
@@ -6335,6 +6426,388 @@ export const MODELS = {
maxTokens: 262144,
} satisfies Model<"openai-completions">,
},
"nvidia": {
"meta/llama-3.1-70b-instruct": {
id: "meta/llama-3.1-70b-instruct",
name: "Llama 3.1 70b Instruct",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta/llama-3.1-8b-instruct": {
id: "meta/llama-3.1-8b-instruct",
name: "Llama 3.1 8B Instruct",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 16000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta/llama-3.2-11b-vision-instruct": {
id: "meta/llama-3.2-11b-vision-instruct",
name: "Llama 3.2 11b Vision Instruct",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: false,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"meta/llama-3.2-90b-vision-instruct": {
id: "meta/llama-3.2-90b-vision-instruct",
name: "Llama-3.2-90B-Vision-Instruct",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: false,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"meta/llama-3.3-70b-instruct": {
id: "meta/llama-3.3-70b-instruct",
name: "Llama 3.3 70b Instruct",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mistral-large-3-675b-instruct-2512": {
id: "mistralai/mistral-large-3-675b-instruct-2512",
name: "Mistral Large 3 675B Instruct 2512",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: false,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"mistralai/mistral-small-4-119b-2603": {
id: "mistralai/mistral-small-4-119b-2603",
name: "mistral-small-4-119b-2603",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 8192,
} satisfies Model<"openai-completions">,
"moonshotai/kimi-k2.6": {
id: "moonshotai/kimi-k2.6",
name: "Kimi K2.6",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"nvidia/llama-3.3-nemotron-super-49b-v1": {
id: "nvidia/llama-3.3-nemotron-super-49b-v1",
name: "Llama 3.3 Nemotron Super 49B v1",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"nvidia/llama-3.3-nemotron-super-49b-v1.5": {
id: "nvidia/llama-3.3-nemotron-super-49b-v1.5",
name: "Llama 3.3 Nemotron Super 49B v1.5",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"nvidia/nemotron-3-nano-30b-a3b": {
id: "nvidia/nemotron-3-nano-30b-a3b",
name: "nemotron-3-nano-30b-a3b",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": {
id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
name: "Nemotron 3 Nano Omni",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"nvidia/nemotron-3-super-120b-a12b": {
id: "nvidia/nemotron-3-super-120b-a12b",
name: "Nemotron 3 Super",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text"],
cost: {
input: 0.2,
output: 0.8,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"nvidia/nvidia-nemotron-nano-9b-v2": {
id: "nvidia/nvidia-nemotron-nano-9b-v2",
name: "nvidia-nemotron-nano-9b-v2",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
"openai/gpt-oss-20b": {
id: "openai/gpt-oss-20b",
name: "GPT OSS 20B",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 32768,
} satisfies Model<"openai-completions">,
"qwen/qwen3-coder-480b-a35b-instruct": {
id: "qwen/qwen3-coder-480b-a35b-instruct",
name: "Qwen3 Coder 480B A35B Instruct",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 66536,
} satisfies Model<"openai-completions">,
"qwen/qwen3.5-122b-a10b": {
id: "qwen/qwen3.5-122b-a10b",
name: "Qwen3.5 122B-A10B",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 262144,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"stepfun-ai/step-3.5-flash": {
id: "stepfun-ai/step-3.5-flash",
name: "Step 3.5 Flash",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"stepfun-ai/step-3.7-flash": {
id: "stepfun-ai/step-3.7-flash",
name: "Step 3.7 Flash",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text", "image"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 256000,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"z-ai/glm-5.1": {
id: "z-ai/glm-5.1",
name: "GLM-5.1",
api: "openai-completions",
provider: "nvidia",
baseUrl: "https://integrate.api.nvidia.com/v1",
headers: {"NVCF-POLL-SECONDS":"3600"},
compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"supportsLongCacheRetention":false},
reasoning: true,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 131072,
} satisfies Model<"openai-completions">,
},
"openai": {
"gpt-4": {
id: "gpt-4",
@@ -9307,9 +9780,9 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.3,
output: 2.5,
cacheRead: 0.06,
input: 0.075,
output: 0.625,
cacheRead: 0.015,
cacheWrite: 0,
},
contextWindow: 262144,
@@ -9899,7 +10372,7 @@ export const MODELS = {
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
compat: {"supportsDeveloperRole":false},
compat: {"supportsDeveloperRole":false,"requiresReasoningContentOnAssistantMessages":true},
reasoning: true,
input: ["text", "image"],
cost: {
@@ -9917,7 +10390,7 @@ export const MODELS = {
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
compat: {"supportsDeveloperRole":false},
compat: {"supportsDeveloperRole":false,"requiresReasoningContentOnAssistantMessages":true},
reasoning: true,
input: ["text", "image"],
cost: {
@@ -578,6 +578,11 @@ function buildParams(
} else if (model.thinkingLevelMap?.off !== null) {
openRouterParams.reasoning = { effort: model.thinkingLevelMap?.off ?? "none" };
}
} else if (compat.thinkingFormat === "ant-ling" && model.reasoning && options?.reasoningEffort) {
const effort = model.thinkingLevelMap?.[options.reasoningEffort];
if (typeof effort === "string") {
(params as typeof params & { reasoning?: { effort: string } }).reasoning = { effort };
}
} else if (compat.thinkingFormat === "together" && model.reasoning) {
const togetherParams = params as Omit<typeof params, "reasoning_effort"> & {
reasoning?: { enabled: boolean };
@@ -1078,8 +1083,11 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
const isOpenRouter = provider === "openrouter" || baseUrl.includes("openrouter.ai");
const isCloudflareWorkersAI = provider === "cloudflare-workers-ai" || baseUrl.includes("api.cloudflare.com");
const isCloudflareAiGateway = provider === "cloudflare-ai-gateway" || baseUrl.includes("gateway.ai.cloudflare.com");
const isNvidia = provider === "nvidia" || baseUrl.includes("integrate.api.nvidia.com");
const isAntLing = provider === "ant-ling" || baseUrl.includes("api.ant-ling.com");
const isNonStandard =
isNvidia ||
provider === "cerebras" ||
baseUrl.includes("cerebras.ai") ||
provider === "xai" ||
@@ -1092,18 +1100,23 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
provider === "opencode" ||
baseUrl.includes("opencode.ai") ||
isCloudflareWorkersAI ||
isCloudflareAiGateway;
isCloudflareAiGateway ||
isAntLing;
const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether;
const useMaxTokens =
baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia || isAntLing;
const isGrok = provider === "xai" || baseUrl.includes("api.x.ai");
const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com");
const isOpenRouterDeveloperRoleModel =
isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/"));
const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined;
return {
supportsStore: !isNonStandard,
supportsDeveloperRole: !isNonStandard && !isOpenRouter,
supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway,
supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter),
supportsReasoningEffort:
!isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing,
supportsUsageInStreaming: true,
maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
requiresToolResultName: false,
@@ -1116,16 +1129,24 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet
? "zai"
: isTogether
? "together"
: isOpenRouter
? "openrouter"
: "openai",
: isAntLing
? "ant-ling"
: isOpenRouter
? "openrouter"
: "openai",
openRouterRouting: {},
vercelGatewayRouting: {},
zaiToolStream: false,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway,
supportsStrictMode: !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia,
cacheControlFormat,
sendSessionAffinityHeaders: false,
supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway),
supportsLongCacheRetention: !(
isTogether ||
isCloudflareWorkersAI ||
isCloudflareAiGateway ||
isNvidia ||
isAntLing
),
};
}
+5 -2
View File
@@ -22,12 +22,14 @@ export type ImagesApi = KnownImagesApi | (string & {});
export type KnownProvider =
| "amazon-bedrock"
| "ant-ling"
| "anthropic"
| "google"
| "google-vertex"
| "openai"
| "azure-openai-responses"
| "openai-codex"
| "nvidia"
| "deepseek"
| "github-copilot"
| "xai"
@@ -389,7 +391,7 @@ export interface OpenAICompletionsCompat {
requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, and "string-thinking" uses top-level thinking: string. Default: "openai". */
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort when supported, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, "qwen-chat-template" uses chat_template_kwargs.enable_thinking, "string-thinking" uses top-level thinking: string, and "ant-ling" uses reasoning: { effort } only when the mapped effort is non-null. Default: "openai". */
thinkingFormat?:
| "openai"
| "openrouter"
@@ -398,7 +400,8 @@ export interface OpenAICompletionsCompat {
| "zai"
| "qwen"
| "qwen-chat-template"
| "string-thinking";
| "string-thinking"
| "ant-ling";
/** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
@@ -2,7 +2,7 @@ import { Type } from "typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getModel } from "../src/models.ts";
import { convertMessages } from "../src/providers/openai-completions.ts";
import { streamSimple } from "../src/stream.ts";
import { stream, streamSimple } from "../src/stream.ts";
import type { AssistantMessage, Model, Tool, ToolResultMessage } from "../src/types.ts";
const mockState = vi.hoisted(() => ({
@@ -817,7 +817,7 @@ describe("openai-completions tool_choice", () => {
expect(writeCall).not.toHaveProperty("partialArgs");
});
it("uses system messages for OpenRouter reasoning model instructions", async () => {
it("uses system messages for non-OpenAI/Anthropic OpenRouter reasoning model instructions", async () => {
const model = getModel("openrouter", "deepseek/deepseek-v4-pro")!;
let payload: unknown;
@@ -839,6 +839,33 @@ describe("openai-completions tool_choice", () => {
expect(params.messages?.[0]?.role).toBe("system");
});
it("keeps developer messages for OpenAI and Anthropic OpenRouter reasoning model instructions", async () => {
for (const model of [
getModel("openrouter", "openai/gpt-5.2-codex"),
getModel("openrouter", "anthropic/claude-sonnet-4.5"),
]) {
expect(model).toBeDefined();
let payload: unknown;
await streamSimple(
model!,
{
systemPrompt: "Follow instructions.",
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{
apiKey: "test",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
const params = payload as { messages?: Array<{ role?: string }> };
expect(params.messages?.[0]?.role).toBe("developer");
}
});
it("keeps developer messages for OpenAI reasoning model instructions", async () => {
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-5.5")!;
const model = { ...baseModel, api: "openai-completions" } as const;
@@ -862,6 +889,14 @@ describe("openai-completions tool_choice", () => {
expect(params.messages?.[0]?.role).toBe("developer");
});
it("stores OpenRouter Kimi K2.6 reasoning replay compat in built-in metadata", () => {
for (const modelId of ["moonshotai/kimi-k2.6", "moonshotai/kimi-k2.6:free"] as const) {
const model = getModel("openrouter", modelId)!;
expect(model.compat?.supportsDeveloperRole).toBe(false);
expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBe(true);
}
});
it("stores Xiaomi MiMo reasoning replay compat in built-in metadata", () => {
const providers = ["xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const;
@@ -1260,4 +1295,95 @@ describe("openai-completions tool_choice", () => {
expect(params.reasoning).toEqual({ effort: "high" });
expect(params.reasoning_effort).toBeUndefined();
});
it("uses Ant Ling compatibility metadata", async () => {
const model = getModel("ant-ling", "Ring-2.6-1T")!;
let payload: unknown;
expect(model.compat).toMatchObject({
supportsStore: false,
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "ant-ling",
supportsLongCacheRetention: false,
});
expect(model.compat?.supportsStrictMode).toBeUndefined();
expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBeUndefined();
await streamSimple(
model,
{
systemPrompt: "Follow instructions.",
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{
apiKey: "test",
maxTokens: 123,
reasoning: "high",
cacheRetention: "long",
sessionId: "ant-ling-session",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
const params = (payload ?? mockState.lastParams) as {
max_tokens?: number;
max_completion_tokens?: number;
messages?: Array<{ role?: string }>;
reasoning?: { effort?: string };
reasoning_effort?: string;
store?: boolean;
prompt_cache_key?: string;
prompt_cache_retention?: string;
};
expect(params.max_tokens).toBe(123);
expect(params.max_completion_tokens).toBeUndefined();
expect(params.messages?.[0]?.role).toBe("system");
expect(params.reasoning).toEqual({ effort: "high" });
expect(params.reasoning_effort).toBeUndefined();
expect(params.store).toBeUndefined();
expect(params.prompt_cache_key).toBeUndefined();
expect(params.prompt_cache_retention).toBeUndefined();
});
it("omits Ant Ling reasoning for unmapped direct reasoning efforts and non-reasoning models", async () => {
const ring = getModel("ant-ling", "Ring-2.6-1T")!;
let payload: unknown;
await stream(
ring,
{
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{
apiKey: "test",
reasoningEffort: "medium",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
expect((payload ?? mockState.lastParams) as { reasoning?: unknown }).not.toHaveProperty("reasoning");
const ling = getModel("ant-ling", "Ling-2.6-flash")!;
await streamSimple(
ling,
{
messages: [{ role: "user", content: "Hi", timestamp: Date.now() }],
},
{
apiKey: "test",
reasoning: "high",
onPayload: (params: unknown) => {
payload = params;
},
},
).result();
expect((payload ?? mockState.lastParams) as { reasoning?: unknown }).not.toHaveProperty("reasoning");
});
});
+45
View File
@@ -787,6 +787,30 @@ describe("Generate E2E Tests", () => {
});
});
describe.skipIf(!process.env.NVIDIA_API_KEY)("NVIDIA NIM Provider (Nemotron 3 Super via OpenAI Completions)", () => {
const llm = getModel("nvidia", "nvidia/nemotron-3-super-120b-a12b");
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm);
});
it("should handle thinking mode", { retry: 3 }, async () => {
await handleThinking(llm, { reasoningEffort: "high" });
});
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
await multiTurn(llm, { reasoningEffort: "high" });
});
});
describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter Provider (glm-4.5v via OpenAI Completions)", () => {
const llm = getModel("openrouter", "z-ai/glm-4.5v");
@@ -1145,6 +1169,27 @@ describe("Generate E2E Tests", () => {
},
);
describe.skipIf(!process.env.ANT_LING_API_KEY)("Ant Ling Provider (Ling 2.6 Flash via OpenAI Completions)", () => {
const llm = getModel("ant-ling", "Ling-2.6-flash");
it("should complete basic text generation", { retry: 3 }, async () => {
await basicTextGeneration(llm);
});
it("should handle tool calling", { retry: 3 }, async () => {
await handleToolCall(llm);
});
it("should handle streaming", { retry: 3 }, async () => {
await handleStreaming(llm);
});
it("should handle thinking mode", { retry: 3 }, async () => {
const ringModel = getModel("ant-ling", "Ring-2.6-1T");
await handleThinking(ringModel, { reasoningEffort: "high" });
});
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// Tokens are resolved at module level (see oauthTokens above)
+4
View File
@@ -4,14 +4,18 @@
### Added
- Added Ant Ling provider selection and setup documentation.
- Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers.
- Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode.
- Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs.
### Fixed
- Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters.
- Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)).
- Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)).
- Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)).
- Fixed `renderShell: "self"` tool renderers that emit no component lines leaving a blank chat row ([#5299](https://github.com/earendil-works/pi/issues/5299)).
## [0.78.0] - 2026-05-29
+4 -2
View File
@@ -110,9 +110,11 @@ For each built-in provider, pi maintains a list of tool-capable models, updated
**API keys:**
- Anthropic
- Ant Ling
- OpenAI
- Azure OpenAI
- DeepSeek
- NVIDIA NIM
- Google Gemini
- Google Vertex
- Amazon Bedrock
@@ -296,7 +298,7 @@ Interactive startup asks before loading `.pi` or `.pi.user` in a working directo
Pi has two separate startup features:
- **Update check:** fetches `https://pi.dev/api/latest-version` to check whether a newer Pi version exists. Disable it with `PI_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check.
- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.
- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. This setting also controls optional provider attribution headers for OpenRouter, Cloudflare, and direct NVIDIA NIM requests. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.
Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.
@@ -648,7 +650,7 @@ pi --thinking high "Solve this complex problem"
| `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) |
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |
+4
View File
@@ -49,9 +49,11 @@ pi
| Provider | Environment Variable | `auth.json` key |
|----------|----------------------|------------------|
| Anthropic | `ANTHROPIC_API_KEY` | `anthropic` |
| Ant Ling | `ANT_LING_API_KEY` | `ant-ling` |
| Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
| OpenAI | `OPENAI_API_KEY` | `openai` |
| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
| NVIDIA NIM | `NVIDIA_API_KEY` | `nvidia` |
| Google Gemini | `GEMINI_API_KEY` | `google` |
| Mistral | `MISTRAL_API_KEY` | `mistral` |
| Groq | `GROQ_API_KEY` | `groq` |
@@ -84,8 +86,10 @@ Store credentials in `~/.pi/agent/auth.json`:
```json
{
"anthropic": { "type": "api_key", "key": "sk-ant-..." },
"ant-ling": { "type": "api_key", "key": "..." },
"openai": { "type": "api_key", "key": "sk-..." },
"deepseek": { "type": "api_key", "key": "sk-..." },
"nvidia": { "type": "api_key", "key": "nvapi-..." },
"google": { "type": "api_key", "key": "..." },
"opencode": { "type": "api_key", "key": "..." },
"opencode-go": { "type": "api_key", "key": "..." },
+1 -1
View File
@@ -282,7 +282,7 @@ pi --exclude-tools ask_question
| `PI_PACKAGE_DIR` | Override package directory, useful for Nix/Guix store paths |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported |
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |
+2
View File
@@ -332,6 +332,7 @@ ${chalk.bold("Examples:")}
${chalk.bold("Environment Variables:")}
ANTHROPIC_API_KEY - Anthropic Claude API key
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
ANT_LING_API_KEY - Ant Ling API key
OPENAI_API_KEY - OpenAI GPT API key
AZURE_OPENAI_API_KEY - Azure OpenAI API key
AZURE_OPENAI_BASE_URL - Azure OpenAI/Cognitive Services base URL (e.g. https://{resource}.openai.azure.com)
@@ -339,6 +340,7 @@ ${chalk.bold("Environment Variables:")}
AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1)
AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated)
DEEPSEEK_API_KEY - DeepSeek API key
NVIDIA_API_KEY - NVIDIA NIM API key
GEMINI_API_KEY - Google Gemini API key
GROQ_API_KEY - Groq API key
CEREBRAS_API_KEY - Cerebras API key
@@ -2787,6 +2787,7 @@ export class AgentSession {
customInstructions,
replaceInstructions,
reserveTokens: branchSummarySettings.reserveTokens,
streamFn: this.agent.streamFn,
});
if (result.aborted) {
return { cancelled: true, aborted: true };
@@ -5,8 +5,8 @@
* a summary of the branch being left so context isn't lost.
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core";
import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import {
convertToLlm,
@@ -77,6 +77,8 @@ export interface GenerateBranchSummaryOptions {
replaceInstructions?: boolean;
/** Tokens reserved for prompt + LLM response (default 16384) */
reserveTokens?: number;
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
streamFn?: StreamFn;
}
// ============================================================================
@@ -284,7 +286,16 @@ export async function generateBranchSummary(
entries: SessionEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
const {
model,
apiKey,
headers,
signal,
customInstructions,
replaceInstructions,
reserveTokens = 16384,
streamFn,
} = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
@@ -320,12 +331,14 @@ export async function generateBranchSummary(
},
];
// Call LLM for summarization
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, headers, signal, maxTokens: 2048 },
);
// Call LLM for summarization. Prefer the session stream function so SDK
// request behavior (timeouts, retries, attribution headers) stays consistent
// without running through agent state/events.
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
const requestOptions: SimpleStreamOptions = { apiKey, headers, signal, maxTokens: 2048 };
const response = streamFn
? await (await streamFn(model, context, requestOptions)).result()
: await completeSimple(model, context, requestOptions);
// Check if aborted or errored
if (response.stopReason === "aborted") {
@@ -613,6 +613,18 @@
.replace(/'/g, '&#39;');
}
function sanitizeMarkdownUrl(value) {
const href = String(value || '').trim().replace(/[\x00-\x1f\x7f]/g, '');
if (!href) return href;
const scheme = href.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
if (scheme && !/^(https?|mailto|tel|ftp)$/i.test(scheme[1])) {
return null;
}
return href;
}
/**
* Truncate string to maxLen chars, append "..." if truncated.
*/
@@ -1569,10 +1581,11 @@
}
},
renderer: {
// Sanitize link URLs to prevent javascript:/vbscript:/data: XSS
// Sanitize link URLs with a scheme allow-list. Browsers strip C0
// controls from schemes, so strip them before checking and emitting.
link(token) {
const href = (token.href || '').trim();
if (/^\s*(javascript|vbscript|data):/i.test(href)) {
const href = sanitizeMarkdownUrl(token.href);
if (href === null) {
return this.parser.parseInline(token.tokens);
}
let out = '<a href="' + escapeHtml(href) + '"';
@@ -1582,10 +1595,10 @@
out += '>' + this.parser.parseInline(token.tokens) + '</a>';
return out;
},
// Sanitize image src URLs
// Sanitize image src URLs with the same scheme allow-list.
image(token) {
const href = (token.href || '').trim();
if (/^\s*(javascript|vbscript|data):/i.test(href)) {
const href = sanitizeMarkdownUrl(token.href);
if (href === null) {
return escapeHtml(token.text || '');
}
let out = '<img src="' + escapeHtml(href) + '" alt="' + escapeHtml(token.text || '') + '"';
@@ -13,10 +13,12 @@ import type { ModelRegistry } from "./model-registry.ts";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
"ant-ling": "Ring-2.6-1T",
anthropic: "claude-opus-4-8",
openai: "gpt-5.4",
"azure-openai-responses": "gpt-5.4",
"openai-codex": "gpt-5.5",
nvidia: "nvidia/nemotron-3-super-120b-a12b",
deepseek: "deepseek-v4-pro",
google: "gemini-3.1-pro-preview",
"google-vertex": "gemini-3.1-pro-preview",
@@ -0,0 +1,97 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import type { SettingsManager } from "./settings-manager.ts";
import { isInstallTelemetryEnabled } from "./telemetry.ts";
const OPENROUTER_HOST = "openrouter.ai";
const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
const CLOUDFLARE_API_HOST = "api.cloudflare.com";
const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com";
const OPENCODE_HOST = "opencode.ai";
function matchesHost(baseUrl: string, expectedHost: string): boolean {
try {
return new URL(baseUrl).hostname === expectedHost;
} catch {
return false;
}
}
function isOpenRouterModel(model: Model<Api>): boolean {
return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST);
}
function isNvidiaNimModel(model: Model<Api>): boolean {
return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST);
}
function isCloudflareModel(model: Model<Api>): boolean {
return (
model.provider === "cloudflare-workers-ai" ||
model.provider === "cloudflare-ai-gateway" ||
matchesHost(model.baseUrl, CLOUDFLARE_API_HOST) ||
matchesHost(model.baseUrl, CLOUDFLARE_AI_GATEWAY_HOST)
);
}
function getDefaultAttributionHeaders(
model: Model<Api>,
settingsManager: SettingsManager,
): Record<string, string> | undefined {
if (!isInstallTelemetryEnabled(settingsManager)) {
return undefined;
}
if (isOpenRouterModel(model)) {
return {
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
"X-OpenRouter-Categories": "cli-agent",
};
}
if (isNvidiaNimModel(model)) {
return {
"X-BILLING-INVOKE-ORIGIN": "Pi",
};
}
if (isCloudflareModel(model)) {
return {
"User-Agent": "pi-coding-agent",
};
}
return undefined;
}
function getSessionHeaders(model: Model<Api>, sessionId: string | undefined): Record<string, string> | undefined {
if (!sessionId) return undefined;
if (
model.provider !== "opencode" &&
model.provider !== "opencode-go" &&
!matchesHost(model.baseUrl, OPENCODE_HOST)
) {
return undefined;
}
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
}
export function mergeProviderAttributionHeaders(
model: Model<Api>,
settingsManager: SettingsManager,
sessionId: string | undefined,
...headerSources: Array<Record<string, string> | undefined>
): Record<string, string> | undefined {
const merged = {
...getSessionHeaders(model, sessionId),
...getDefaultAttributionHeaders(model, settingsManager),
};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
@@ -1,6 +1,7 @@
export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
"ant-ling": "Ant Ling",
"azure-openai-responses": "Azure OpenAI Responses",
cerebras: "Cerebras",
"cloudflare-ai-gateway": "Cloudflare AI Gateway",
@@ -17,6 +18,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
"minimax-cn": "MiniMax (China)",
moonshotai: "Moonshot AI",
"moonshotai-cn": "Moonshot AI (China)",
nvidia: "NVIDIA NIM",
opencode: "OpenCode Zen",
"opencode-go": "OpenCode Go",
openai: "OpenAI",
+8 -44
View File
@@ -11,11 +11,11 @@ import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefi
import { convertToLlm } from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { findInitialModel } from "./model-resolver.ts";
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import { DefaultResourceLoader } from "./resource-loader.ts";
import { getDefaultSessionDir, SessionManager } from "./session-manager.ts";
import { SettingsManager } from "./settings-manager.ts";
import { isInstallTelemetryEnabled } from "./telemetry.ts";
import { time } from "./timings.ts";
import {
createBashTool,
@@ -128,44 +128,6 @@ function getDefaultAgentDir(): string {
return getAgentDir();
}
function getAttributionHeaders(
model: Model<any>,
settingsManager: SettingsManager,
sessionId?: string,
): Record<string, string> | undefined {
if (
sessionId &&
(model.provider === "opencode" || model.provider === "opencode-go" || model.baseUrl.includes("opencode.ai"))
) {
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
}
if (!isInstallTelemetryEnabled(settingsManager)) {
return undefined;
}
if (model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai")) {
return {
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
"X-OpenRouter-Categories": "cli-agent",
};
}
if (
model.provider === "cloudflare-workers-ai" ||
model.provider === "cloudflare-ai-gateway" ||
model.baseUrl.includes("api.cloudflare.com") ||
model.baseUrl.includes("gateway.ai.cloudflare.com")
) {
return {
"User-Agent": "pi-coding-agent",
};
}
return undefined;
}
/**
* Create an AgentSession with the specified options.
*
@@ -349,7 +311,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
const websocketConnectTimeoutMs =
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId);
return streamSimple(model, context, {
...options,
apiKey: auth.apiKey,
@@ -357,10 +318,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
headers:
attributionHeaders || auth.headers || options?.headers
? { ...attributionHeaders, ...auth.headers, ...options?.headers }
: undefined,
headers: mergeProviderAttributionHeaders(
model,
settingsManager,
options?.sessionId,
auth.headers,
options?.headers,
),
});
},
onPayload: async (payload, _model) => {
@@ -222,6 +222,31 @@ export class ToolExecutionComponent extends Container {
if (this.hideComponent) {
return [];
}
if (this.hasRendererDefinition() && this.getRenderShell() === "self") {
const contentLines = this.selfRenderContainer.render(width);
if (contentLines.length === 0 && this.imageComponents.length === 0) {
return [];
}
const lines: string[] = [];
if (contentLines.length > 0) {
lines.push("");
lines.push(...contentLines);
}
for (let i = 0; i < this.imageComponents.length; i++) {
const spacer = this.imageSpacers[i];
if (spacer) {
lines.push(...spacer.render(width));
}
const imageComponent = this.imageComponents[i];
if (imageComponent) {
lines.push(...imageComponent.render(width));
}
}
return lines;
}
return super.render(width);
}
@@ -4,15 +4,20 @@ import { describe, expect, it } from "vitest";
describe("export HTML markdown link sanitization", () => {
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
it("overrides the marked link renderer to block javascript: protocol", () => {
// The custom link renderer must check for dangerous protocols
it("overrides the marked link renderer to use scheme allow-list sanitization", () => {
expect(templateJs).toMatch(/link\s*\(\s*token\s*\)/);
expect(templateJs).toMatch(/javascript/i);
expect(templateJs).toMatch(/vbscript/i);
expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/);
expect(templateJs).toMatch(/\^\(https\?\|mailto\|tel\|ftp\)/);
});
it("overrides the marked image renderer to block javascript: protocol", () => {
it("overrides the marked image renderer to use scheme allow-list sanitization", () => {
expect(templateJs).toMatch(/image\s*\(\s*token\s*\)/);
expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/);
});
it("strips C0 controls before checking and emitting markdown URLs", () => {
expect(templateJs).toContain("replace(/[\\x00-\\x1f\\x7f]/g, '')");
expect(templateJs).not.toMatch(/\^\\s\*\(javascript\|vbscript\|data\):/i);
});
it("escapes href attributes in the custom link renderer", () => {
@@ -378,11 +378,12 @@ describe("default model selection", () => {
expect(defaultModelPerProvider["openai-codex"]).toBe("gpt-5.5");
});
test("zai, minimax, and cerebras defaults track current models", () => {
test("zai, minimax, cerebras, and ant-ling defaults track current models", () => {
expect(defaultModelPerProvider.zai).toBe("glm-5.1");
expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7");
expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7");
expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7");
expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T");
});
test("ai-gateway default tracks current model", () => {
@@ -15,14 +15,14 @@ import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
describe("createAgentSession OpenRouter attribution headers", () => {
describe("createAgentSession provider attribution headers", () => {
let tempDir: string;
let cwd: string;
let agentDir: string;
let originalTelemetryEnv: string | undefined;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-sdk-openrouter-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
tempDir = join(tmpdir(), `pi-sdk-attribution-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
cwd = join(tempDir, "project");
agentDir = join(tempDir, "agent");
mkdirSync(cwd, { recursive: true });
@@ -42,9 +42,9 @@ describe("createAgentSession OpenRouter attribution headers", () => {
}
});
function createModel(provider: string, baseUrl: string): Model<Api> {
function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model<Api> {
return {
id: `${provider}-test-model`,
id,
name: `${provider} Test Model`,
api: "openai-completions",
provider,
@@ -172,6 +172,14 @@ describe("createAgentSession OpenRouter attribution headers", () => {
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("preserves legacy OpenRouter base URL substring attribution matching", async () => {
const headers = await captureHeaders(createModel("custom-openrouter", "not-a-url-openrouter.ai"));
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("lets provider and request headers override the defaults", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
providerHeaders: {
@@ -188,6 +196,56 @@ describe("createAgentSession OpenRouter attribution headers", () => {
expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category");
});
it("adds default attribution headers for direct NVIDIA NIM endpoints", async () => {
const headers = await captureHeaders(createModel("custom-nim", "https://integrate.api.nvidia.com/v1"));
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
});
it("adds default attribution headers for the NVIDIA provider", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://example.test/v1"));
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
});
it("does not add NVIDIA NIM attribution headers when telemetry is disabled", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
telemetryEnabled: false,
});
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("lets provider and request headers override NVIDIA NIM defaults", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
providerHeaders: {
"X-BILLING-INVOKE-ORIGIN": "Provider",
},
requestHeaders: {
"X-BILLING-INVOKE-ORIGIN": "Request",
},
});
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Request");
});
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through OpenRouter", async () => {
const headers = await captureHeaders(
createModel("openrouter", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-super-120b-a12b"),
);
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through Vercel AI Gateway", async () => {
const headers = await captureHeaders(
createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1", "nvidia/nemotron-3-super-120b-a12b"),
);
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("adds OpenCode session headers", async () => {
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
@@ -119,10 +119,10 @@ describe("createAgentSession stream options", () => {
expect(options?.timeoutMs).toBe(1234);
});
it("does not default timeoutMs from httpIdleTimeoutMs for other providers", async () => {
it("defaults timeoutMs from httpIdleTimeoutMs for all providers", async () => {
const options = await captureStreamOptions("openai-completions", { httpIdleTimeoutMs: 1234 });
expect(options?.timeoutMs).toBeUndefined();
expect(options?.timeoutMs).toBe(1234);
});
it("lets request timeoutMs override httpIdleTimeoutMs for OpenAI Codex", async () => {
@@ -67,6 +67,37 @@ describe("ToolExecutionComponent parity", () => {
expect(rendered).toContain("custom result");
});
test("self-rendered empty tool rows take no layout space", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderShell: "self",
renderCall: () => new Text("", 0, 0),
renderResult: () => new Text("", 0, 0),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-empty-self-render",
{},
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
expect(component.render(120)).toEqual([]);
component.updateResult(
{
content: [],
details: {},
isError: false,
},
false,
);
expect(component.render(120)).toEqual([]);
});
test("uses built-in rendering for built-in overrides without custom renderers", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("edit"),