Merge branch 'main' into approvals

This commit is contained in:
Armin Ronacher
2026-06-02 17:27:38 +02:00
28 changed files with 1252 additions and 107 deletions
+4
View File
@@ -4,14 +4,18 @@
### Added
- Added Ant Ling provider selection and setup documentation.
- Added NVIDIA NIM provider selection, setup documentation, and direct NIM request attribution headers.
- Added `ctx.mode` to extension contexts so extensions can distinguish TUI, RPC, JSON, and print mode.
- Added `ctx.getSystemPromptOptions()` for extension commands to inspect the current base system prompt inputs.
### Fixed
- Fixed stored XSS in HTML session exports by sanitizing Markdown link and image URLs with a scheme allow-list after stripping control characters.
- Fixed SDK embedding in bundled Node apps failing with `ENOENT` when `package.json` is not present next to the bundle entrypoint. The package metadata reader now gracefully handles missing `package.json` by using defaults, enabling `createAgentSession()` without requiring package-adjacent files at runtime ([#5226](https://github.com/earendil-works/pi/issues/5226)).
- Fixed HTTP timeout setting not being respected for non-Codex providers (e.g., llama.cpp via OpenAI-compatible API). The `httpIdleTimeoutMs` setting (set via `/settings` HTTP timeout) now applies as the default SDK request timeout for all providers that support it, not just OpenAI Codex Responses. Disabling the timeout (HTTP timeout = false) now correctly disables SDK timeouts for all supported providers by sending a maximum int32 value (effectively infinite) instead of 0, since SDKs treat timeout=0 as an immediate timeout ([#5294](https://github.com/earendil-works/pi/issues/5294)).
- Fixed opening and listing very large JSONL session files by reading session entries line-by-line instead of materializing the full file as one string ([#5231](https://github.com/earendil-works/pi/issues/5231)).
- Fixed `renderShell: "self"` tool renderers that emit no component lines leaving a blank chat row ([#5299](https://github.com/earendil-works/pi/issues/5299)).
## [0.78.0] - 2026-05-29
+4 -2
View File
@@ -110,9 +110,11 @@ For each built-in provider, pi maintains a list of tool-capable models, updated
**API keys:**
- Anthropic
- Ant Ling
- OpenAI
- Azure OpenAI
- DeepSeek
- NVIDIA NIM
- Google Gemini
- Google Vertex
- Amazon Bedrock
@@ -296,7 +298,7 @@ Interactive startup asks before loading `.pi` or `.pi.user` in a working directo
Pi has two separate startup features:
- **Update check:** fetches `https://pi.dev/api/latest-version` to check whether a newer Pi version exists. Disable it with `PI_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check.
- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.
- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. This setting also controls optional provider attribution headers for OpenRouter, Cloudflare, and direct NVIDIA NIM requests. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.
Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.
@@ -648,7 +650,7 @@ pi --thinking high "Solve this complex problem"
| `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) |
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |
+4
View File
@@ -49,9 +49,11 @@ pi
| Provider | Environment Variable | `auth.json` key |
|----------|----------------------|------------------|
| Anthropic | `ANTHROPIC_API_KEY` | `anthropic` |
| Ant Ling | `ANT_LING_API_KEY` | `ant-ling` |
| Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
| OpenAI | `OPENAI_API_KEY` | `openai` |
| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
| NVIDIA NIM | `NVIDIA_API_KEY` | `nvidia` |
| Google Gemini | `GEMINI_API_KEY` | `google` |
| Mistral | `MISTRAL_API_KEY` | `mistral` |
| Groq | `GROQ_API_KEY` | `groq` |
@@ -84,8 +86,10 @@ Store credentials in `~/.pi/agent/auth.json`:
```json
{
"anthropic": { "type": "api_key", "key": "sk-ant-..." },
"ant-ling": { "type": "api_key", "key": "..." },
"openai": { "type": "api_key", "key": "sk-..." },
"deepseek": { "type": "api_key", "key": "sk-..." },
"nvidia": { "type": "api_key", "key": "nvapi-..." },
"google": { "type": "api_key", "key": "..." },
"opencode": { "type": "api_key", "key": "..." },
"opencode-go": { "type": "api_key", "key": "..." },
+1 -1
View File
@@ -282,7 +282,7 @@ pi --exclude-tools ask_question
| `PI_PACKAGE_DIR` | Override package directory, useful for Nix/Guix store paths |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
| `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks |
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported |
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |
+2
View File
@@ -332,6 +332,7 @@ ${chalk.bold("Examples:")}
${chalk.bold("Environment Variables:")}
ANTHROPIC_API_KEY - Anthropic Claude API key
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
ANT_LING_API_KEY - Ant Ling API key
OPENAI_API_KEY - OpenAI GPT API key
AZURE_OPENAI_API_KEY - Azure OpenAI API key
AZURE_OPENAI_BASE_URL - Azure OpenAI/Cognitive Services base URL (e.g. https://{resource}.openai.azure.com)
@@ -339,6 +340,7 @@ ${chalk.bold("Environment Variables:")}
AZURE_OPENAI_API_VERSION - Azure OpenAI API version (default: v1)
AZURE_OPENAI_DEPLOYMENT_NAME_MAP - Azure OpenAI model=deployment map (comma-separated)
DEEPSEEK_API_KEY - DeepSeek API key
NVIDIA_API_KEY - NVIDIA NIM API key
GEMINI_API_KEY - Google Gemini API key
GROQ_API_KEY - Groq API key
CEREBRAS_API_KEY - Cerebras API key
@@ -2787,6 +2787,7 @@ export class AgentSession {
customInstructions,
replaceInstructions,
reserveTokens: branchSummarySettings.reserveTokens,
streamFn: this.agent.streamFn,
});
if (result.aborted) {
return { cancelled: true, aborted: true };
@@ -5,8 +5,8 @@
* a summary of the branch being left so context isn't lost.
*/
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { Model } from "@earendil-works/pi-ai";
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core";
import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import {
convertToLlm,
@@ -77,6 +77,8 @@ export interface GenerateBranchSummaryOptions {
replaceInstructions?: boolean;
/** Tokens reserved for prompt + LLM response (default 16384) */
reserveTokens?: number;
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
streamFn?: StreamFn;
}
// ============================================================================
@@ -284,7 +286,16 @@ export async function generateBranchSummary(
entries: SessionEntry[],
options: GenerateBranchSummaryOptions,
): Promise<BranchSummaryResult> {
const { model, apiKey, headers, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
const {
model,
apiKey,
headers,
signal,
customInstructions,
replaceInstructions,
reserveTokens = 16384,
streamFn,
} = options;
// Token budget = context window minus reserved space for prompt + response
const contextWindow = model.contextWindow || 128000;
@@ -320,12 +331,14 @@ export async function generateBranchSummary(
},
];
// Call LLM for summarization
const response = await completeSimple(
model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
{ apiKey, headers, signal, maxTokens: 2048 },
);
// Call LLM for summarization. Prefer the session stream function so SDK
// request behavior (timeouts, retries, attribution headers) stays consistent
// without running through agent state/events.
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
const requestOptions: SimpleStreamOptions = { apiKey, headers, signal, maxTokens: 2048 };
const response = streamFn
? await (await streamFn(model, context, requestOptions)).result()
: await completeSimple(model, context, requestOptions);
// Check if aborted or errored
if (response.stopReason === "aborted") {
@@ -613,6 +613,18 @@
.replace(/'/g, '&#39;');
}
function sanitizeMarkdownUrl(value) {
const href = String(value || '').trim().replace(/[\x00-\x1f\x7f]/g, '');
if (!href) return href;
const scheme = href.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
if (scheme && !/^(https?|mailto|tel|ftp)$/i.test(scheme[1])) {
return null;
}
return href;
}
/**
* Truncate string to maxLen chars, append "..." if truncated.
*/
@@ -1569,10 +1581,11 @@
}
},
renderer: {
// Sanitize link URLs to prevent javascript:/vbscript:/data: XSS
// Sanitize link URLs with a scheme allow-list. Browsers strip C0
// controls from schemes, so strip them before checking and emitting.
link(token) {
const href = (token.href || '').trim();
if (/^\s*(javascript|vbscript|data):/i.test(href)) {
const href = sanitizeMarkdownUrl(token.href);
if (href === null) {
return this.parser.parseInline(token.tokens);
}
let out = '<a href="' + escapeHtml(href) + '"';
@@ -1582,10 +1595,10 @@
out += '>' + this.parser.parseInline(token.tokens) + '</a>';
return out;
},
// Sanitize image src URLs
// Sanitize image src URLs with the same scheme allow-list.
image(token) {
const href = (token.href || '').trim();
if (/^\s*(javascript|vbscript|data):/i.test(href)) {
const href = sanitizeMarkdownUrl(token.href);
if (href === null) {
return escapeHtml(token.text || '');
}
let out = '<img src="' + escapeHtml(href) + '" alt="' + escapeHtml(token.text || '') + '"';
@@ -13,10 +13,12 @@ import type { ModelRegistry } from "./model-registry.ts";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
"ant-ling": "Ring-2.6-1T",
anthropic: "claude-opus-4-8",
openai: "gpt-5.4",
"azure-openai-responses": "gpt-5.4",
"openai-codex": "gpt-5.5",
nvidia: "nvidia/nemotron-3-super-120b-a12b",
deepseek: "deepseek-v4-pro",
google: "gemini-3.1-pro-preview",
"google-vertex": "gemini-3.1-pro-preview",
@@ -0,0 +1,97 @@
import type { Api, Model } from "@earendil-works/pi-ai";
import type { SettingsManager } from "./settings-manager.ts";
import { isInstallTelemetryEnabled } from "./telemetry.ts";
const OPENROUTER_HOST = "openrouter.ai";
const NVIDIA_NIM_HOST = "integrate.api.nvidia.com";
const CLOUDFLARE_API_HOST = "api.cloudflare.com";
const CLOUDFLARE_AI_GATEWAY_HOST = "gateway.ai.cloudflare.com";
const OPENCODE_HOST = "opencode.ai";
function matchesHost(baseUrl: string, expectedHost: string): boolean {
try {
return new URL(baseUrl).hostname === expectedHost;
} catch {
return false;
}
}
function isOpenRouterModel(model: Model<Api>): boolean {
return model.provider === "openrouter" || model.baseUrl.includes(OPENROUTER_HOST);
}
function isNvidiaNimModel(model: Model<Api>): boolean {
return model.provider === "nvidia" || matchesHost(model.baseUrl, NVIDIA_NIM_HOST);
}
function isCloudflareModel(model: Model<Api>): boolean {
return (
model.provider === "cloudflare-workers-ai" ||
model.provider === "cloudflare-ai-gateway" ||
matchesHost(model.baseUrl, CLOUDFLARE_API_HOST) ||
matchesHost(model.baseUrl, CLOUDFLARE_AI_GATEWAY_HOST)
);
}
function getDefaultAttributionHeaders(
model: Model<Api>,
settingsManager: SettingsManager,
): Record<string, string> | undefined {
if (!isInstallTelemetryEnabled(settingsManager)) {
return undefined;
}
if (isOpenRouterModel(model)) {
return {
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
"X-OpenRouter-Categories": "cli-agent",
};
}
if (isNvidiaNimModel(model)) {
return {
"X-BILLING-INVOKE-ORIGIN": "Pi",
};
}
if (isCloudflareModel(model)) {
return {
"User-Agent": "pi-coding-agent",
};
}
return undefined;
}
function getSessionHeaders(model: Model<Api>, sessionId: string | undefined): Record<string, string> | undefined {
if (!sessionId) return undefined;
if (
model.provider !== "opencode" &&
model.provider !== "opencode-go" &&
!matchesHost(model.baseUrl, OPENCODE_HOST)
) {
return undefined;
}
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
}
export function mergeProviderAttributionHeaders(
model: Model<Api>,
settingsManager: SettingsManager,
sessionId: string | undefined,
...headerSources: Array<Record<string, string> | undefined>
): Record<string, string> | undefined {
const merged = {
...getSessionHeaders(model, sessionId),
...getDefaultAttributionHeaders(model, settingsManager),
};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
@@ -1,6 +1,7 @@
export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
anthropic: "Anthropic",
"amazon-bedrock": "Amazon Bedrock",
"ant-ling": "Ant Ling",
"azure-openai-responses": "Azure OpenAI Responses",
cerebras: "Cerebras",
"cloudflare-ai-gateway": "Cloudflare AI Gateway",
@@ -17,6 +18,7 @@ export const BUILT_IN_PROVIDER_DISPLAY_NAMES: Record<string, string> = {
"minimax-cn": "MiniMax (China)",
moonshotai: "Moonshot AI",
"moonshotai-cn": "Moonshot AI (China)",
nvidia: "NVIDIA NIM",
opencode: "OpenCode Zen",
"opencode-go": "OpenCode Go",
openai: "OpenAI",
+8 -44
View File
@@ -11,11 +11,11 @@ import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefi
import { convertToLlm } from "./messages.ts";
import { ModelRegistry } from "./model-registry.ts";
import { findInitialModel } from "./model-resolver.ts";
import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
import type { ResourceLoader } from "./resource-loader.ts";
import { DefaultResourceLoader } from "./resource-loader.ts";
import { getDefaultSessionDir, SessionManager } from "./session-manager.ts";
import { SettingsManager } from "./settings-manager.ts";
import { isInstallTelemetryEnabled } from "./telemetry.ts";
import { time } from "./timings.ts";
import {
createBashTool,
@@ -128,44 +128,6 @@ function getDefaultAgentDir(): string {
return getAgentDir();
}
function getAttributionHeaders(
model: Model<any>,
settingsManager: SettingsManager,
sessionId?: string,
): Record<string, string> | undefined {
if (
sessionId &&
(model.provider === "opencode" || model.provider === "opencode-go" || model.baseUrl.includes("opencode.ai"))
) {
return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
}
if (!isInstallTelemetryEnabled(settingsManager)) {
return undefined;
}
if (model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai")) {
return {
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
"X-OpenRouter-Categories": "cli-agent",
};
}
if (
model.provider === "cloudflare-workers-ai" ||
model.provider === "cloudflare-ai-gateway" ||
model.baseUrl.includes("api.cloudflare.com") ||
model.baseUrl.includes("gateway.ai.cloudflare.com")
) {
return {
"User-Agent": "pi-coding-agent",
};
}
return undefined;
}
/**
* Create an AgentSession with the specified options.
*
@@ -349,7 +311,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
const websocketConnectTimeoutMs =
options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
const attributionHeaders = getAttributionHeaders(model, settingsManager, options?.sessionId);
return streamSimple(model, context, {
...options,
apiKey: auth.apiKey,
@@ -357,10 +318,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
headers:
attributionHeaders || auth.headers || options?.headers
? { ...attributionHeaders, ...auth.headers, ...options?.headers }
: undefined,
headers: mergeProviderAttributionHeaders(
model,
settingsManager,
options?.sessionId,
auth.headers,
options?.headers,
),
});
},
onPayload: async (payload, _model) => {
@@ -222,6 +222,31 @@ export class ToolExecutionComponent extends Container {
if (this.hideComponent) {
return [];
}
if (this.hasRendererDefinition() && this.getRenderShell() === "self") {
const contentLines = this.selfRenderContainer.render(width);
if (contentLines.length === 0 && this.imageComponents.length === 0) {
return [];
}
const lines: string[] = [];
if (contentLines.length > 0) {
lines.push("");
lines.push(...contentLines);
}
for (let i = 0; i < this.imageComponents.length; i++) {
const spacer = this.imageSpacers[i];
if (spacer) {
lines.push(...spacer.render(width));
}
const imageComponent = this.imageComponents[i];
if (imageComponent) {
lines.push(...imageComponent.render(width));
}
}
return lines;
}
return super.render(width);
}
@@ -4,15 +4,20 @@ import { describe, expect, it } from "vitest";
describe("export HTML markdown link sanitization", () => {
const templateJs = readFileSync(new URL("../src/core/export-html/template.js", import.meta.url), "utf-8");
it("overrides the marked link renderer to block javascript: protocol", () => {
// The custom link renderer must check for dangerous protocols
it("overrides the marked link renderer to use scheme allow-list sanitization", () => {
expect(templateJs).toMatch(/link\s*\(\s*token\s*\)/);
expect(templateJs).toMatch(/javascript/i);
expect(templateJs).toMatch(/vbscript/i);
expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/);
expect(templateJs).toMatch(/\^\(https\?\|mailto\|tel\|ftp\)/);
});
it("overrides the marked image renderer to block javascript: protocol", () => {
it("overrides the marked image renderer to use scheme allow-list sanitization", () => {
expect(templateJs).toMatch(/image\s*\(\s*token\s*\)/);
expect(templateJs).toMatch(/sanitizeMarkdownUrl\(token\.href\)/);
});
it("strips C0 controls before checking and emitting markdown URLs", () => {
expect(templateJs).toContain("replace(/[\\x00-\\x1f\\x7f]/g, '')");
expect(templateJs).not.toMatch(/\^\\s\*\(javascript\|vbscript\|data\):/i);
});
it("escapes href attributes in the custom link renderer", () => {
@@ -378,11 +378,12 @@ describe("default model selection", () => {
expect(defaultModelPerProvider["openai-codex"]).toBe("gpt-5.5");
});
test("zai, minimax, and cerebras defaults track current models", () => {
test("zai, minimax, cerebras, and ant-ling defaults track current models", () => {
expect(defaultModelPerProvider.zai).toBe("glm-5.1");
expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7");
expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7");
expect(defaultModelPerProvider.cerebras).toBe("zai-glm-4.7");
expect(defaultModelPerProvider["ant-ling"]).toBe("Ring-2.6-1T");
});
test("ai-gateway default tracks current model", () => {
@@ -15,14 +15,14 @@ import { createAgentSession } from "../src/core/sdk.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
describe("createAgentSession OpenRouter attribution headers", () => {
describe("createAgentSession provider attribution headers", () => {
let tempDir: string;
let cwd: string;
let agentDir: string;
let originalTelemetryEnv: string | undefined;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-sdk-openrouter-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
tempDir = join(tmpdir(), `pi-sdk-attribution-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
cwd = join(tempDir, "project");
agentDir = join(tempDir, "agent");
mkdirSync(cwd, { recursive: true });
@@ -42,9 +42,9 @@ describe("createAgentSession OpenRouter attribution headers", () => {
}
});
function createModel(provider: string, baseUrl: string): Model<Api> {
function createModel(provider: string, baseUrl: string, id = `${provider}-test-model`): Model<Api> {
return {
id: `${provider}-test-model`,
id,
name: `${provider} Test Model`,
api: "openai-completions",
provider,
@@ -172,6 +172,14 @@ describe("createAgentSession OpenRouter attribution headers", () => {
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("preserves legacy OpenRouter base URL substring attribution matching", async () => {
const headers = await captureHeaders(createModel("custom-openrouter", "not-a-url-openrouter.ai"));
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("lets provider and request headers override the defaults", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
providerHeaders: {
@@ -188,6 +196,56 @@ describe("createAgentSession OpenRouter attribution headers", () => {
expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category");
});
it("adds default attribution headers for direct NVIDIA NIM endpoints", async () => {
const headers = await captureHeaders(createModel("custom-nim", "https://integrate.api.nvidia.com/v1"));
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
});
it("adds default attribution headers for the NVIDIA provider", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://example.test/v1"));
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Pi");
});
it("does not add NVIDIA NIM attribution headers when telemetry is disabled", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
telemetryEnabled: false,
});
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("lets provider and request headers override NVIDIA NIM defaults", async () => {
const headers = await captureHeaders(createModel("nvidia", "https://integrate.api.nvidia.com/v1"), {
providerHeaders: {
"X-BILLING-INVOKE-ORIGIN": "Provider",
},
requestHeaders: {
"X-BILLING-INVOKE-ORIGIN": "Request",
},
});
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBe("Request");
});
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through OpenRouter", async () => {
const headers = await captureHeaders(
createModel("openrouter", "https://openrouter.ai/api/v1", "nvidia/nemotron-3-super-120b-a12b"),
);
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("does not add NVIDIA NIM attribution headers for NVIDIA models routed through Vercel AI Gateway", async () => {
const headers = await captureHeaders(
createModel("vercel-ai-gateway", "https://ai-gateway.vercel.sh/v1", "nvidia/nemotron-3-super-120b-a12b"),
);
expect(headers?.["X-BILLING-INVOKE-ORIGIN"]).toBeUndefined();
});
it("adds OpenCode session headers", async () => {
const headers = await captureHeaders(createModel("opencode", "https://opencode.ai/zen/v1"), {
sessionId: "opencode-session",
@@ -119,10 +119,10 @@ describe("createAgentSession stream options", () => {
expect(options?.timeoutMs).toBe(1234);
});
it("does not default timeoutMs from httpIdleTimeoutMs for other providers", async () => {
it("defaults timeoutMs from httpIdleTimeoutMs for all providers", async () => {
const options = await captureStreamOptions("openai-completions", { httpIdleTimeoutMs: 1234 });
expect(options?.timeoutMs).toBeUndefined();
expect(options?.timeoutMs).toBe(1234);
});
it("lets request timeoutMs override httpIdleTimeoutMs for OpenAI Codex", async () => {
@@ -67,6 +67,37 @@ describe("ToolExecutionComponent parity", () => {
expect(rendered).toContain("custom result");
});
test("self-rendered empty tool rows take no layout space", () => {
const toolDefinition: ToolDefinition = {
...createBaseToolDefinition(),
renderShell: "self",
renderCall: () => new Text("", 0, 0),
renderResult: () => new Text("", 0, 0),
};
const component = new ToolExecutionComponent(
"custom_tool",
"tool-empty-self-render",
{},
{},
toolDefinition,
createFakeTui(),
process.cwd(),
);
expect(component.render(120)).toEqual([]);
component.updateResult(
{
content: [],
details: {},
isError: false,
},
false,
);
expect(component.render(120)).toEqual([]);
});
test("uses built-in rendering for built-in overrides without custom renderers", () => {
const overrideDefinition: ToolDefinition = {
...createBaseToolDefinition("edit"),