Merge remote-tracking branch 'origin/main' into dev

This commit is contained in:
David Brailovsky 2026-08-14 09:33:00 +02:00
commit fd389abc46
17 changed files with 244 additions and 58 deletions

View file

@ -4,6 +4,7 @@
### Changed
- Changed Kimi Coding requests to use pi's runtime `User-Agent` header.
- Automatically converted supported strict tool schemas to provider-compatible closed objects with required nullable optional fields while preserving original tool definitions, and treated `null` values for optional non-nullable tool arguments as omitted.
- Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)).
- Replaced the Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead.

View file

@ -148,10 +148,6 @@ const COPILOT_STATIC_HEADERS = {
"Copilot-Integration-Id": "vscode-chat",
} as const;
const KIMI_STATIC_HEADERS = {
"User-Agent": "KimiCLI/1.5",
} as const;
const TOGETHER_BASE_URL = "https://api.together.ai/v1";
const TOGETHER_BASE_COMPAT: OpenAICompletionsCompat = {
supportsStore: false,
@ -2057,7 +2053,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
provider: "kimi-coding",
// Kimi For Coding's Anthropic-compatible API - SDK appends /v1/messages
baseUrl: "https://api.kimi.com/coding",
headers: { ...KIMI_STATIC_HEADERS },
compat: {
...(allowEmptySignature ? { allowEmptySignature: true } : {}),
forceAdaptiveThinking: true,

View file

@ -33,6 +33,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { getProviderEnvValue } from "../utils/provider-env.ts";
import { retryProviderRequest } from "../utils/provider-retry.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
@ -271,6 +272,20 @@ function mergeHeaders(...headerSources: (ProviderHeaders | undefined)[]): Provid
return merged;
}
function mergeClientHeaders(
model: Model<"anthropic-messages">,
...headerSources: (ProviderHeaders | undefined)[]
): ProviderHeaders {
const merged = mergeHeaders(...headerSources);
if (model.provider === "kimi-coding") {
for (const name of Object.keys(merged)) {
if (name.toLowerCase() === "user-agent") delete merged[name];
}
merged["User-Agent"] = getPiUserAgent();
}
return merged;
}
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
if (!headers) return false;
const expected = name.toLowerCase();
@ -872,7 +887,8 @@ function createClient(
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: mergeHeaders(
defaultHeaders: mergeClientHeaders(
model,
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
@ -895,7 +911,8 @@ function createClient(
baseURL: model.baseUrl,
dangerouslyAllowBrowser: true,
fetch,
defaultHeaders: mergeHeaders(
defaultHeaders: mergeClientHeaders(
model,
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",
@ -914,7 +931,8 @@ function createClient(
// API key or header-owned auth.
const sessionAffinityHeaders: ProviderHeaders =
sessionId && getAnthropicCompat(model).sendSessionAffinityHeaders ? { "x-session-affinity": sessionId } : {};
const defaultHeaders = mergeHeaders(
const defaultHeaders = mergeClientHeaders(
model,
{
accept: "application/json",
"anthropic-dangerous-direct-browser-access": "true",

View file

@ -1,4 +1,3 @@
import type * as NodeOs from "node:os";
import type * as NodeZlib from "node:zlib";
import type {
Tool as OpenAITool,
@ -7,20 +6,6 @@ import type {
ResponseStreamEvent,
} from "openai/resources/responses/responses.js";
type ProcessWithOsBuiltinModule = typeof process & {
getBuiltinModule?: (id: "node:os") => typeof NodeOs;
};
function loadNodeOs(): typeof NodeOs | null {
if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
return null;
}
return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null;
}
// NEVER convert to top-level runtime imports - breaks browser/Vite builds
const _os: typeof NodeOs | null = loadNodeOs();
import { clampThinkingLevel } from "../models.ts";
import { registerSessionResourceCleanup } from "../session-resources.ts";
import type {
@ -46,6 +31,7 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { headersToRecord } from "../utils/headers.ts";
import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
import { uuidv7 } from "../utils/uuid.ts";
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
@ -1618,8 +1604,7 @@ function buildBaseCodexHeaders(
headers.set("Authorization", `Bearer ${token}`);
headers.set("chatgpt-account-id", accountId);
headers.set("originator", "pi");
const userAgent = _os ? `pi (${_os.platform()} ${_os.release()}; ${_os.arch()})` : "pi (browser)";
headers.set("User-Agent", userAgent);
headers.set("User-Agent", getPiUserAgent());
return headers;
}

View file

@ -0,0 +1,19 @@
import type * as NodeOs from "node:os";
type ProcessWithOsBuiltinModule = typeof process & {
getBuiltinModule?: (id: "node:os") => typeof NodeOs;
};
function loadNodeOs(): typeof NodeOs | null {
if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) {
return null;
}
return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null;
}
// Keep runtime OS loading browser-safe. A top-level runtime import of node:os breaks browser/Vite builds.
const nodeOs = loadNodeOs();
export function getPiUserAgent(): string {
return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)";
}

View file

@ -1,3 +1,4 @@
import { arch, platform, release } from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
import { ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../src/env-api-keys.ts";
@ -71,6 +72,14 @@ const anthropicModel: Model<"anthropic-messages"> = {
maxTokens: 4096,
};
const kimiCodingModel: Model<"anthropic-messages"> = {
...anthropicModel,
id: "kimi-for-coding",
name: "Kimi For Coding",
provider: "kimi-coding",
baseUrl: "https://api.kimi.com/coding",
};
afterEach(() => {
mockState.constructorOpts = undefined;
mockState.createParams = undefined;
@ -185,3 +194,23 @@ describe("Anthropic auth token env", () => {
expect(headers.Authorization).toBe("Bearer explicit-token");
});
});
describe("Anthropic-compatible user agents", () => {
it("enforces the pi runtime user agent for Kimi Coding", async () => {
await streamAnthropic(kimiCodingModel, context, {
apiKey: "kimi-key",
headers: { "user-agent": "custom-client" },
}).result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
const userAgentHeaders = Object.entries(headers).filter(([name]) => name.toLowerCase() === "user-agent");
expect(userAgentHeaders).toEqual([["User-Agent", `pi (${platform()} ${release()}; ${arch()})`]]);
});
it("does not apply the pi runtime user agent to Anthropic", async () => {
await streamAnthropic(anthropicModel, context, { apiKey: "anthropic-key" }).result();
const headers = mockState.constructorOpts?.defaultHeaders as Record<string, string>;
expect(Object.keys(headers).some((name) => name.toLowerCase() === "user-agent")).toBe(false);
});
});

View file

@ -1,5 +1,5 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { arch, platform, release, tmpdir } from "node:os";
import { join } from "node:path";
import { zstdDecompressSync } from "node:zlib";
import { Type } from "typebox";
@ -160,6 +160,7 @@ describe("openai-codex streaming", () => {
expect(headers?.get("chatgpt-account-id")).toBe("acc_test");
expect(headers?.get("OpenAI-Beta")).toBe("responses=experimental");
expect(headers?.get("originator")).toBe("pi");
expect(headers?.get("User-Agent")).toBe(`pi (${platform()} ${release()}; ${arch()})`);
expect(headers?.get("accept")).toBe("text/event-stream");
expect(headers?.has("x-api-key")).toBe(false);
return new Response(stream, {

View file

@ -12,11 +12,13 @@
### Changed
- Changed inherited Kimi Coding requests to use pi's runtime `User-Agent` header.
- Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead.
- Documented the generic `AI_AGENT=pi` process marker and how it differs from `PI_CODING_AGENT=true` ([#7747](https://github.com/earendil-works/pi/issues/7747)).
### Fixed
- Fixed managed-tool downloads delaying TUI startup and hiding diagnostics in fullscreen mode by mounting the TUI first and showing download progress and warnings inside it.
- Fixed opening a model selector immediately after startup cancelling and restarting the in-progress model catalog refresh.
- Fixed inherited GitHub Copilot login triggering API rate limits while enabling model policies by limiting concurrent policy updates ([#6187](https://github.com/earendil-works/pi/issues/6187)).
- Fixed fullscreen transcript search snapping back to the current match during manual scrolling and fragmented mouse input leaking into the search query.

View file

@ -192,6 +192,20 @@ Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explic
| `shellCommandPrefix` | string | - | Prefix for every bash command (e.g., `"shopt -s expand_aliases"`) |
| `npmCommand` | string[] | - | Command argv used for npm package lookup/install operations (e.g., `["mise", "exec", "node@20", "--", "npm"]`) |
Windows paths in JSON must use forward slashes or escaped backslashes:
```json
{
"shellPath": "C:/Program Files/Git/bin/bash.exe"
}
```
```json
{
"shellPath": "C:\\Program Files\\Git\\bin\\bash.exe"
}
```
```json
{
"npmCommand": ["mise", "exec", "node@20", "--", "npm"]

View file

@ -222,7 +222,7 @@ export function createFindToolDefinition(
}
// Default implementation uses fd.
const fdPath = await ensureTool("fd", true);
const fdPath = await ensureTool("fd");
if (signal?.aborted) {
settle(() => reject(new Error("Operation aborted")));
return;

View file

@ -174,7 +174,7 @@ export function createGrepToolDefinition(
(async () => {
try {
const rgPath = await ensureTool("rg", true);
const rgPath = await ensureTool("rg");
if (!rgPath) {
settle(() => reject(new Error("ripgrep (rg) is not available and could not be downloaded")));
return;

View file

@ -107,7 +107,7 @@ import { openBrowser } from "../../utils/open-browser.ts";
import { getCwdRelativePath } from "../../utils/paths.ts";
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
import { ensureTool } from "../../utils/tools-manager.ts";
import { ensureTool, type ToolStatus } from "../../utils/tools-manager.ts";
import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.ts";
import { ArminComponent } from "./components/armin.ts";
import { AssistantMessageComponent } from "./components/assistant-message.ts";
@ -436,6 +436,7 @@ export class InteractiveMode {
// Status line tracking (for mutating immediately-sequential status updates)
private lastStatusSpacer: Spacer | undefined = undefined;
private lastStatusText: Text | undefined = undefined;
private managedToolStatusStarted = false;
// Streaming message tracking
private streamingComponent: AssistantMessageComponent | undefined = undefined;
@ -851,11 +852,6 @@ export class InteractiveMode {
// Load changelog (only show new entries, skip for resumed sessions)
this.changelogMarkdown = this.getChangelogForDisplay();
// Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
// Both are needed: fd for autocomplete, rg for grep tool and bash commands
const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
this.fdPath = fdPath;
if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
const modelList = this.session.scopedModels
.map((sm) => {
@ -901,11 +897,12 @@ export class InteractiveMode {
this.widgetContainerBelow,
this.footerContainer,
]);
// Accept text while startup completes, but only enable interrupt, exit, and submission feedback.
this.defaultEditor.onAction("app.clear", () => this.handleCtrlC());
this.defaultEditor.onCtrlD = () => this.handleCtrlD();
this.defaultEditor.onSubmit = (text) => this.handleStartupSubmit(text);
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
// Start the UI before initializing extensions so session_start handlers can use interactive dialogs
this.ui.start();
this.isInitialized = true;
@ -974,6 +971,20 @@ export class InteractiveMode {
}
this.ui.requestRender();
// Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir)
// so slow downloads do not make startup appear frozen.
// Both are needed: fd for autocomplete, rg for grep tool and bash commands.
const [fdPath] = await Promise.all([
ensureTool("fd", (status) => this.showManagedToolStatus(status)),
ensureTool("rg", (status) => this.showManagedToolStatus(status)),
]);
this.fdPath = fdPath;
// Enable the remaining input handlers only after managed-tool setup completes.
this.setupKeyHandlers();
this.setupEditorSubmitHandler();
this.ui.requestRender();
// Initialize extensions first so resources are shown before messages
await this.rebindCurrentSession();
@ -2873,6 +2884,11 @@ export class InteractiveMode {
}
}
private handleStartupSubmit(text: string): void {
this.editor.setText(text);
this.showStatus("Startup is still in progress");
}
private setupEditorSubmitHandler(): void {
this.defaultEditor.onSubmit = async (text: string) => {
text = text.trim();
@ -3408,6 +3424,20 @@ export class InteractiveMode {
return textBlocks.map((c) => (c as { text: string }).text).join("");
}
/** Show a managed-tool status update in the chat. */
private showManagedToolStatus(status: ToolStatus): void {
if (!this.managedToolStatusStarted) {
this.chatContainer.addChild(new Spacer(1));
this.managedToolStatusStarted = true;
}
const message = status.type === "warning" ? `Warning: ${status.message}` : status.message;
const color = status.type === "warning" ? "warning" : "dim";
this.chatContainer.addChild(new Text(theme.fg(color, message), 1, 0));
this.lastStatusSpacer = undefined;
this.lastStatusText = undefined;
this.ui.requestRender();
}
/**
* Show a status message in the chat.
*

View file

@ -1,4 +1,3 @@
import chalk from "chalk";
import { type SpawnSyncReturns, spawnSync } from "child_process";
import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs";
import { arch, platform } from "os";
@ -323,9 +322,20 @@ const TERMUX_PACKAGES: Record<string, string> = {
rg: "ripgrep",
};
// Ensure a tool is available, downloading if necessary
// Returns the path to the tool, or null if unavailable
export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise<string | undefined> {
export interface ToolStatus {
type: "info" | "warning";
message: string;
}
/**
* Ensure a tool is available, downloading if necessary.
* Reports progress through `onStatus`; status messages are otherwise silent.
* Returns the tool path, or undefined if unavailable.
*/
export async function ensureTool(
tool: "fd" | "rg",
onStatus?: (status: ToolStatus) => void,
): Promise<string | undefined> {
const existingPath = getToolPath(tool);
if (existingPath) {
return existingPath;
@ -335,9 +345,7 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr
if (!config) return undefined;
if (isOfflineModeEnabled()) {
if (!silent) {
console.log(chalk.yellow(`${config.name} not found. Offline mode enabled, skipping download.`));
}
onStatus?.({ type: "warning", message: `${config.name} not found. Offline mode enabled, skipping download.` });
return undefined;
}
@ -345,27 +353,22 @@ export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Pr
// Users must install via pkg.
if (platform() === "android") {
const pkgName = TERMUX_PACKAGES[tool] ?? tool;
if (!silent) {
console.log(chalk.yellow(`${config.name} not found. Install with: pkg install ${pkgName}`));
}
onStatus?.({ type: "warning", message: `${config.name} not found. Install with: pkg install ${pkgName}` });
return undefined;
}
// Tool not found - download it
if (!silent) {
console.log(chalk.dim(`${config.name} not found. Downloading...`));
}
onStatus?.({ type: "info", message: `${config.name} not found. Downloading...` });
try {
const path = await downloadTool(tool);
if (!silent) {
console.log(chalk.dim(`${config.name} installed to ${path}`));
}
onStatus?.({ type: "info", message: `${config.name} installed to ${path}` });
return path;
} catch (e) {
if (!silent) {
console.log(chalk.yellow(`Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`));
}
onStatus?.({
type: "warning",
message: `Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`,
});
return undefined;
}
}

View file

@ -23,7 +23,13 @@ type InputContext = {
pendingUserInputs: string[];
};
type StartupSubmitContext = {
editor: { setText: (text: string) => void };
showStatus: (message: string) => void;
};
type InteractiveModePrivate = {
handleStartupSubmit(this: StartupSubmitContext, text: string): void;
setupEditorSubmitHandler(this: SubmitContext): void;
getUserInput(this: InputContext): Promise<string>;
};
@ -49,6 +55,18 @@ function createSubmitContext(): SubmitContext {
}
describe("InteractiveMode startup input", () => {
it("restores a prompt submitted while managed-tool setup is running", () => {
const context: StartupSubmitContext = {
editor: { setText: vi.fn() },
showStatus: vi.fn(),
};
interactiveModePrototype.handleStartupSubmit.call(context, "early prompt");
expect(context.editor.setText).toHaveBeenCalledWith("early prompt");
expect(context.showStatus).toHaveBeenCalledWith("Startup is still in progress");
});
it("queues a normal prompt submitted before the input callback is installed", async () => {
const context = createSubmitContext();
interactiveModePrototype.setupEditorSubmitHandler.call(context);

View file

@ -118,6 +118,30 @@ describe("InteractiveMode.showStatus", () => {
});
});
describe("InteractiveMode.showManagedToolStatus", () => {
beforeAll(() => initTheme("dark"));
test("renders tool updates as one contiguous group", () => {
const fakeThis: any = {
chatContainer: new Container(),
ui: { requestRender: vi.fn() },
managedToolStatusStarted: false,
lastStatusSpacer: undefined,
lastStatusText: undefined,
};
const showManagedToolStatus = (InteractiveMode as any).prototype.showManagedToolStatus;
showManagedToolStatus.call(fakeThis, { type: "info", message: "fd downloading" });
showManagedToolStatus.call(fakeThis, { type: "info", message: "rg downloading" });
showManagedToolStatus.call(fakeThis, { type: "warning", message: "rg failed" });
expect(fakeThis.chatContainer.children).toHaveLength(4);
expect(normalizeRenderedOutput(fakeThis.chatContainer)).toBe(
"fd downloading\n rg downloading\n Warning: rg failed",
);
});
});
describe("InteractiveMode.setToolsExpanded", () => {
test("applies expansion state to the active header and chat entries", () => {
const header = { setExpanded: vi.fn() };

View file

@ -59,7 +59,7 @@ async function createCloudflareRuntime(): Promise<{ modelRuntime: ModelRuntime;
describe("ModelRegistry Cloudflare compat streaming", () => {
it("materializes the Cloudflare endpoint through ModelRuntime streaming", async () => {
const { modelRuntime } = await createCloudflareRuntime();
const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5");
const model = modelRuntime.getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
expect(model).toBeDefined();
resetApiProviders();
@ -75,7 +75,7 @@ describe("ModelRegistry Cloudflare compat streaming", () => {
it("materializes the Cloudflare endpoint after extension-style auth resolution", async () => {
const { modelRegistry } = await createCloudflareRuntime();
const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.5");
const model = modelRegistry.find("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
expect(model).toBeDefined();
resetApiProviders();

View file

@ -0,0 +1,47 @@
import type * as ChildProcess from "node:child_process";
import type * as Fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ensureTool, type ToolStatus } from "../src/utils/tools-manager.ts";
const originalOffline = process.env.PI_OFFLINE;
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof Fs>();
return {
...actual,
existsSync: vi.fn(() => false),
};
});
vi.mock("child_process", async (importOriginal) => {
const actual = await importOriginal<typeof ChildProcess>();
return {
...actual,
spawnSync: vi.fn(() => ({ error: new Error("not found") })),
};
});
afterEach(() => {
if (originalOffline === undefined) delete process.env.PI_OFFLINE;
else process.env.PI_OFFLINE = originalOffline;
});
describe("ensureTool", () => {
it("reports status through a callback without writing to the console", async () => {
process.env.PI_OFFLINE = "1";
const statuses: ToolStatus[] = [];
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
const result = await ensureTool("fd", (status) => statuses.push(status));
expect(result).toBeUndefined();
expect(statuses).toEqual([
{
type: "warning",
message: "fd not found. Offline mode enabled, skipping download.",
},
]);
expect(consoleLog).not.toHaveBeenCalled();
consoleLog.mockRestore();
});
});