From 601480122970ebc3a9b5ce8059f6b20dd8390e78 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:09:36 +0200 Subject: [PATCH 01/10] Add NVIDIA NIM provider --- packages/ai/CHANGELOG.md | 4 + packages/ai/README.md | 6 +- packages/ai/scripts/generate-models.ts | 97 +++++ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 363 ++++++++++++++++++ .../ai/src/providers/openai-completions.ts | 10 +- packages/ai/src/types.ts | 1 + packages/ai/test/stream.test.ts | 24 ++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 5 +- packages/coding-agent/docs/providers.md | 2 + packages/coding-agent/docs/usage.md | 2 +- packages/coding-agent/src/cli/args.ts | 1 + .../coding-agent/src/core/agent-session.ts | 1 + .../core/compaction/branch-summarization.ts | 31 +- .../coding-agent/src/core/model-resolver.ts | 1 + .../src/core/provider-attribution.ts | 97 +++++ .../src/core/provider-display-names.ts | 1 + packages/coding-agent/src/core/sdk.ts | 52 +-- .../test/sdk-openrouter-attribution.test.ts | 66 +++- 20 files changed, 700 insertions(+), 66 deletions(-) create mode 100644 packages/coding-agent/src/core/provider-attribution.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index af63cdc4a..4a616f893 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- 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)). diff --git a/packages/ai/README.md b/packages/ai/README.md index e6624d3b8..76a987692 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -54,6 +54,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **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 +802,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 +924,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 { @@ -1102,6 +1103,7 @@ In Node.js environments, you can set environment variables to avoid passing API | 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` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 50df88f2b..8fb62d145 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -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,39 @@ 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-122b-a10b", + "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", @@ -312,6 +350,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> { + 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(); + + 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[]> { try { console.log("Fetching models from OpenRouter API..."); @@ -435,6 +497,7 @@ async function loadModelsDevData(): Promise[]> { const data = await response.json(); const models: Model[] = []; + const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map(); // Process Amazon Bedrock models if (data["amazon-bedrock"]?.models) { @@ -836,6 +899,40 @@ async function loadModelsDevData(): Promise[]> { } } + // 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) { diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index a00e8470a..6321a03a1 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -101,6 +101,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { const envMap: Record = { 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", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 8e83c5808..cd022b795 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6335,6 +6335,369 @@ 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">, + "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", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ae06326e1..c58cbf46c 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1078,8 +1078,10 @@ 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 isNonStandard = + isNvidia || provider === "cerebras" || baseUrl.includes("cerebras.ai") || provider === "xai" || @@ -1094,7 +1096,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet isCloudflareWorkersAI || isCloudflareAiGateway; - const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether; + const useMaxTokens = baseUrl.includes("chutes.ai") || isMoonshot || isCloudflareAiGateway || isTogether || isNvidia; const isGrok = provider === "xai" || baseUrl.includes("api.x.ai"); const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com"); @@ -1103,7 +1105,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet return { supportsStore: !isNonStandard, supportsDeveloperRole: !isNonStandard && !isOpenRouter, - supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway, + supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, @@ -1122,10 +1124,10 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet 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), }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index d0b053ea3..7a4487720 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -28,6 +28,7 @@ export type KnownProvider = | "openai" | "azure-openai-responses" | "openai-codex" + | "nvidia" | "deepseek" | "github-copilot" | "xai" diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 298104dd4..1693f4066 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -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"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 85b26f8f7..28401d532 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- 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. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 0cf352551..585a93a0c 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -113,6 +113,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated - OpenAI - Azure OpenAI - DeepSeek +- NVIDIA NIM - Google Gemini - Google Vertex - Amazon Bedrock @@ -290,7 +291,7 @@ See [docs/settings.md](docs/settings.md) for all options. 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. @@ -641,7 +642,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 | diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 07b6ba085..e7a77c5aa 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -52,6 +52,7 @@ pi | 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` | @@ -86,6 +87,7 @@ Store credentials in `~/.pi/agent/auth.json`: "anthropic": { "type": "api_key", "key": "sk-ant-..." }, "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": "..." }, diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 47d08769c..2f2a64493 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -275,7 +275,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 | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 2decae247..8156bd03f 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -335,6 +335,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 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 556ca20e2..de76a6198 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2787,6 +2787,7 @@ export class AgentSession { customInstructions, replaceInstructions, reserveTokens: branchSummarySettings.reserveTokens, + streamFn: this.agent.streamFn, }); if (result.aborted) { return { cancelled: true, aborted: true }; diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index 0039bc79d..f6ce3d677 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -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 { - 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") { diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 87f7354e7..e3af076da 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -17,6 +17,7 @@ export const defaultModelPerProvider: Record = { 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", diff --git a/packages/coding-agent/src/core/provider-attribution.ts b/packages/coding-agent/src/core/provider-attribution.ts new file mode 100644 index 000000000..91265c92d --- /dev/null +++ b/packages/coding-agent/src/core/provider-attribution.ts @@ -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): boolean { + return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST); +} + +function isNvidiaNimModel(model: Model): boolean { + return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST); +} + +function isCloudflareModel(model: Model): 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, + settingsManager: SettingsManager, +): Record | 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, sessionId: string | undefined): Record | 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, + settingsManager: SettingsManager, + sessionId: string | undefined, + ...headerSources: Array | undefined> +): Record | 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; +} diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 24f7b3ced..789f31530 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -17,6 +17,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { "minimax-cn": "MiniMax (China)", moonshotai: "Moonshot AI", "moonshotai-cn": "Moonshot AI (China)", + nvidia: "NVIDIA NIM", opencode: "OpenCode Zen", "opencode-go": "OpenCode Go", openai: "OpenAI", diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7a1c922b5..e6db747b7 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -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, - settingsManager: SettingsManager, - sessionId?: string, -): Record | 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) => { diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index c70321030..9f035189a 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -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 { + function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model { 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", From 6e269edfd0497cc124482e66acde7e62de2509f1 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:10:36 +0200 Subject: [PATCH 02/10] Restore NVIDIA Qwen 3.5 122B model --- packages/ai/scripts/generate-models.ts | 1 - packages/ai/src/models.generated.ts | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 8fb62d145..12c926d13 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -150,7 +150,6 @@ const NVIDIA_NIM_UNSUPPORTED_MODELS = new Set([ "mistralai/mistral-nemotron", "nvidia/nemotron-mini-4b-instruct", "qwen/qwen3-next-80b-a3b-instruct", - "qwen/qwen3.5-122b-a10b", "qwen/qwen3.5-397b-a17b", "sarvamai/sarvam-m", "upstage/solar-10.7b-instruct", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index cd022b795..1dacf48d5 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -6640,6 +6640,25 @@ export const MODELS = { 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", From 0d38e17b6894b689bb09e1cc3c944d82726ab396 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:25:27 +0200 Subject: [PATCH 03/10] Fix empty self-rendered tool rows closes #5299 --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/components/tool-execution.ts | 25 +++++++++++++++ .../test/tool-execution-component.test.ts | 31 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 28401d532..a9f3c9268 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - 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 diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index b941dfc88..ad84f4413 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -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); } diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 7a4a813da..bf890959d 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -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"), From 2125221bfce8e1a6fa02f8db5ff7820890f986a8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:36:52 +0200 Subject: [PATCH 04/10] Fix OpenRouter Kimi reasoning compat closes #5309 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 6 ++- packages/ai/src/models.generated.ts | 4 +- .../ai/src/providers/openai-completions.ts | 4 +- .../openai-completions-tool-choice.test.ts | 37 ++++++++++++++++++- 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 4a616f893..e927bdcaf 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ - 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 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 12c926d13..b0f495711 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1343,7 +1343,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; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 1dacf48d5..b3ff24a97 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -10281,7 +10281,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: { @@ -10299,7 +10299,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: { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index c58cbf46c..ae6d583e3 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -1100,11 +1100,13 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet 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, + supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index 5f40c8176..c06ac344e 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -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; From 276cb1bb89977d83651bae4ea9b00f634c1670ad Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 15:53:37 +0200 Subject: [PATCH 05/10] Fix stale SDK stream options test after httpIdleTimeoutMs universalization --- packages/coding-agent/test/sdk-stream-options.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/sdk-stream-options.test.ts b/packages/coding-agent/test/sdk-stream-options.test.ts index e30eff7a8..1d565c7e3 100644 --- a/packages/coding-agent/test/sdk-stream-options.test.ts +++ b/packages/coding-agent/test/sdk-stream-options.test.ts @@ -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 () => { From 7e72ca47c8bff19bcec96a98745ca4a3a7624051 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 16:00:29 +0200 Subject: [PATCH 06/10] Add MiniMax-M3 to direct minimax providers closes #5313 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 12 +------- packages/ai/src/models.generated.ts | 40 ++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e927bdcaf..3814e2b2f 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- 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 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index b0f495711..93a664e23 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1657,17 +1657,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]; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index b3ff24a97..97001d3a3 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -5564,6 +5564,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 +5617,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": { @@ -9689,9 +9723,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, From 6cb23f9b5d5b6d1747672f535b167d0d809ac010 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 16:30:14 +0200 Subject: [PATCH 07/10] Fix HTML export URL sanitization --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/export-html/template.js | 25 ++++++++++++++----- .../coding-agent/test/export-html-xss.test.ts | 15 +++++++---- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a9f3c9268..82c509e41 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -10,6 +10,7 @@ ### 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)). diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index fe5c6d91c..cfdd16120 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -613,6 +613,18 @@ .replace(/'/g, '''); } + 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 = ''; 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 = '' + escapeHtml(token.text || '') + ' { 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", () => { From 25a4a8ed1e16c28fa7eceefdff78cbb00e18867e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 2 Jun 2026 16:37:44 +0200 Subject: [PATCH 08/10] Add Ant Ling provider --- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 4 +- packages/ai/scripts/generate-models.ts | 63 +++++++++++++ packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 57 ++++++++++++ .../ai/src/providers/openai-completions.ts | 31 +++++-- packages/ai/src/types.ts | 6 +- .../openai-completions-tool-choice.test.ts | 93 ++++++++++++++++++- packages/ai/test/stream.test.ts | 21 +++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 1 + packages/coding-agent/docs/providers.md | 2 + packages/coding-agent/src/cli/args.ts | 1 + .../coding-agent/src/core/model-resolver.ts | 1 + .../src/core/provider-display-names.ts | 1 + .../coding-agent/test/model-resolver.test.ts | 3 +- 16 files changed, 275 insertions(+), 12 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 3814e2b2f..6e6769be3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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. diff --git a/packages/ai/README.md b/packages/ai/README.md index 76a987692..8f65bc727 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -51,6 +51,7 @@ 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** @@ -939,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: {}) @@ -1100,6 +1101,7 @@ 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` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 93a664e23..c98f868cd 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -169,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", @@ -330,6 +339,10 @@ function applyThinkingLevelMetadata(model: Model): 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 { @@ -1643,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 = { diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 6321a03a1..510f3b387 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -99,6 +99,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { } const envMap: Record = { + "ant-ling": "ANT_LING_API_KEY", openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", nvidia: "NVIDIA_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 97001d3a3..54acc9529 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -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", diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ae6d583e3..5093d3e88 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -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 & { reasoning?: { enabled: boolean }; @@ -1079,6 +1084,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet 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 || @@ -1094,9 +1100,11 @@ 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 || isNvidia; + 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"); @@ -1107,7 +1115,8 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAIComplet return { supportsStore: !isNonStandard, supportsDeveloperRole: isOpenRouterDeveloperRoleModel || (!isNonStandard && !isOpenRouter), - supportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia, + supportsReasoningEffort: + !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAiGateway && !isNvidia && !isAntLing, supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: false, @@ -1120,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 && !isNvidia, cacheControlFormat, sendSessionAffinityHeaders: false, - supportsLongCacheRetention: !(isTogether || isCloudflareWorkersAI || isCloudflareAiGateway || isNvidia), + supportsLongCacheRetention: !( + isTogether || + isCloudflareWorkersAI || + isCloudflareAiGateway || + isNvidia || + isAntLing + ), }; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 7a4487720..cbf136a63 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -22,6 +22,7 @@ export type ImagesApi = KnownImagesApi | (string & {}); export type KnownProvider = | "amazon-bedrock" + | "ant-ling" | "anthropic" | "google" | "google-vertex" @@ -390,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" @@ -399,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. */ diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index c06ac344e..be6419d61 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -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(() => ({ @@ -1295,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"); + }); }); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 1693f4066..9a70b6c28 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1169,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) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 82c509e41..46eebe64b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 585a93a0c..8ef336c79 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -110,6 +110,7 @@ For each built-in provider, pi maintains a list of tool-capable models, updated **API keys:** - Anthropic +- Ant Ling - OpenAI - Azure OpenAI - DeepSeek diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index e7a77c5aa..0ecf439df 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -49,6 +49,7 @@ 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` | @@ -85,6 +86,7 @@ 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-..." }, diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 8156bd03f..458bd43ee 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -328,6 +328,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) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index e3af076da..a00471c9c 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -13,6 +13,7 @@ import type { ModelRegistry } from "./model-registry.ts"; /** Default model IDs for each known provider */ export const defaultModelPerProvider: Record = { "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", diff --git a/packages/coding-agent/src/core/provider-display-names.ts b/packages/coding-agent/src/core/provider-display-names.ts index 789f31530..3481e6aef 100644 --- a/packages/coding-agent/src/core/provider-display-names.ts +++ b/packages/coding-agent/src/core/provider-display-names.ts @@ -1,6 +1,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record = { anthropic: "Anthropic", "amazon-bedrock": "Amazon Bedrock", + "ant-ling": "Ant Ling", "azure-openai-responses": "Azure OpenAI Responses", cerebras: "Cerebras", "cloudflare-ai-gateway": "Cloudflare AI Gateway", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 7339200bc..0ac549394 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -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", () => { From e30b1b18d0bf1db233c1a4c8407361cd5837b7bd Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 2 Jun 2026 17:03:40 +0200 Subject: [PATCH 09/10] Added security file --- SECURITY.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..0a249014b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,70 @@ +# 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 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 write access to trusted local state/config (`~/.pi`, workspace + files, `AGENTS.md`, skills/extensions config), unless they show how an attacker + gets that write 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. + +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. From 0462d44f5695b49ef6e27ab3cd225219085a5b2b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 2 Jun 2026 17:24:41 +0200 Subject: [PATCH 10/10] doc: clarify wording in SECURITY.md --- SECURITY.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 0a249014b..ddd75e14d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,6 +8,14 @@ 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 @@ -47,9 +55,13 @@ on `pi.dev`. - Public internet exposure of a Pi installation - Prompt injection attacks - Exposed secrets that are third-party/user-controlled credentials -- Reports requiring write access to trusted local state/config (`~/.pi`, workspace - files, `AGENTS.md`, skills/extensions config), unless they show how an attacker - gets that write access. +- 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. @@ -62,6 +74,11 @@ 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