From e429d90b800f9a37c8a5812f4c9c10a8cdcc85a7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 14 Aug 2026 13:06:21 +0200 Subject: [PATCH 01/24] fix(coding-agent): update Z.AI Coding Plan defaults closes #8096 --- packages/coding-agent/CHANGELOG.md | 4 ++++ packages/coding-agent/src/core/model-resolver.ts | 4 ++-- packages/coding-agent/test/model-resolver.test.ts | 14 +++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0b7de0847..934b09c73 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). + ## [0.84.2] - 2026-08-14 ### New Features diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index eb70a573b..cfc36ec16 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -35,8 +35,8 @@ export const defaultModelPerProvider: Record = { xai: "grok-4.5", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.7", - zai: "glm-5.1", - "zai-coding-cn": "glm-5.1", + zai: "glm-5.3", + "zai-coding-cn": "glm-5.3", mistral: "devstral-medium-latest", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index f756172d4..ddf83c569 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,4 +1,5 @@ import type { Model } from "@earendil-works/pi-ai"; +import { getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all"; import { describe, expect, test, vi } from "vitest"; import { defaultModelPerProvider, @@ -700,13 +701,24 @@ describe("default model selection", () => { }); test("zai, minimax, cerebras, and ant-ling defaults track current models", () => { - expect(defaultModelPerProvider.zai).toBe("glm-5.1"); + expect(defaultModelPerProvider.zai).toBe("glm-5.3"); + expect(defaultModelPerProvider["zai-coding-cn"]).toBe("glm-5.3"); 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("built-in defaults exist in generated provider catalogs", () => { + for (const provider of getBuiltinProviders()) { + const defaultId = defaultModelPerProvider[provider]; + expect( + getBuiltinModels(provider).some((model) => model.id === defaultId), + `${provider} default ${defaultId} should exist in its generated catalog`, + ).toBe(true); + } + }); + test("ai-gateway default tracks current model", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("zai/glm-5.1"); }); From b1efcf7d7c5d7394fbb12ede0174e04d39ee7004 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Aug 2026 13:46:34 +0000 Subject: [PATCH 02/24] chore: approve contributors from issue #8124 --- .github/APPROVED_CONTRIBUTORS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 4ddc80f14..fb8384f18 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -373,3 +373,5 @@ gwokhou pr gaoyk19 pr cad0p pr + +Jaaneek pr From d5278eaac3446a65e41d5f1392b2c79571858393 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Sat, 15 Aug 2026 07:24:34 +0200 Subject: [PATCH 03/24] fix(ai): enable Copilot model policies sequentially during login Batching policy POSTs at concurrency 4 still bursts 30 requests during login, which trips the Copilot API rate limiter and makes the following GET /models fail with 429, aborting login (#8121). Send the policy updates one at a time instead. --- packages/ai/src/auth/oauth/github-copilot.ts | 10 ++-------- packages/ai/test/github-copilot-oauth.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index 1c9aabc3c..a5aa15ebe 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -16,7 +16,6 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; const COPILOT_API_VERSION = "2026-06-01"; -const COPILOT_POLICY_CONCURRENCY = 4; type DeviceCodeResponse = { device_code: string; @@ -344,13 +343,8 @@ async function enableAllGitHubCopilotModels( enterpriseDomain: string | undefined, signal: AbortSignal, ): Promise { - const models = Object.values(GITHUB_COPILOT_MODELS); - for (let index = 0; index < models.length; index += COPILOT_POLICY_CONCURRENCY) { - await Promise.all( - models.slice(index, index + COPILOT_POLICY_CONCURRENCY).map(async (model) => { - await enableGitHubCopilotModel(token, model.id, enterpriseDomain, signal); - }), - ); + for (const model of Object.values(GITHUB_COPILOT_MODELS)) { + await enableGitHubCopilotModel(token, model.id, enterpriseDomain, signal); } } diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index ae8ae3256..4094291a8 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -239,7 +239,7 @@ describe("GitHub Copilot OAuth device flow", () => { await loginPromise; }); - it("limits concurrent model policy updates during login", async () => { + it("enables model policies sequentially during login", async () => { vi.useFakeTimers(); let activePolicyRequests = 0; @@ -297,8 +297,8 @@ describe("GitHub Copilot OAuth device flow", () => { await vi.advanceTimersByTimeAsync(1000); await loginPromise; - expect(policyRequestCount).toBeGreaterThan(4); - expect(maxActivePolicyRequests).toBe(4); + expect(policyRequestCount).toBeGreaterThan(1); + expect(maxActivePolicyRequests).toBe(1); }); it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { From 086c32e74530564922d011ade23ff582c9d63116 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Sat, 15 Aug 2026 07:33:05 +0200 Subject: [PATCH 04/24] fix(ai): retry Copilot GET /models once on 429 during login The login-time policy updates can drain the Copilot API rate-limit bucket, in which case the follow-up GET /models is rejected with 429 (observed response: retry-after: 2, body 'too many requests') and login aborts. Honor Retry-After and retry once instead of failing (#8121). --- packages/ai/src/auth/oauth/device-code.ts | 2 +- packages/ai/src/auth/oauth/github-copilot.ts | 42 +++++++++---- packages/ai/test/github-copilot-oauth.test.ts | 59 +++++++++++++++++++ 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/auth/oauth/device-code.ts b/packages/ai/src/auth/oauth/device-code.ts index f87c5e3b5..a078155c8 100644 --- a/packages/ai/src/auth/oauth/device-code.ts +++ b/packages/ai/src/auth/oauth/device-code.ts @@ -23,7 +23,7 @@ export type OAuthDeviceCodePollOptions = { signal: AbortSignal; }; -function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise { +export function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new Error(cancelMessage)); diff --git a/packages/ai/src/auth/oauth/github-copilot.ts b/packages/ai/src/auth/oauth/github-copilot.ts index a5aa15ebe..8bd634269 100644 --- a/packages/ai/src/auth/oauth/github-copilot.ts +++ b/packages/ai/src/auth/oauth/github-copilot.ts @@ -4,7 +4,7 @@ import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts"; import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; -import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; +import { abortableSleep, pollOAuthDeviceCodeFlow } from "./device-code.ts"; const decode = (s: string) => atob(s); const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg="); @@ -16,6 +16,8 @@ const COPILOT_HEADERS = { "Copilot-Integration-Id": "vscode-chat", } as const; const COPILOT_API_VERSION = "2026-06-01"; +const MAX_RETRY_AFTER_MS = 10_000; +const DEFAULT_RETRY_AFTER_MS = 1_000; type DeviceCodeResponse = { device_code: string; @@ -121,16 +123,34 @@ async function fetchAvailableGitHubCopilotModelIds( // Some Individual accounts return false for every picker flag despite explicit enabled policies. // Limit the fallback to that endpoint so other account types keep strict picker semantics. const allowPolicyFallback = baseUrl === "https://api.individual.githubcopilot.com"; - const raw = await fetchJson(`${baseUrl}/models`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${copilotToken}`, - ...COPILOT_HEADERS, - "X-GitHub-Api-Version": COPILOT_API_VERSION, - }, - signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), - }); - return parseAvailableCopilotModelIds(raw, allowPolicyFallback); + const request = () => + fetch(`${baseUrl}/models`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${copilotToken}`, + ...COPILOT_HEADERS, + "X-GitHub-Api-Version": COPILOT_API_VERSION, + }, + signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]), + }); + + // The login-time policy updates can drain the Copilot API rate-limit bucket, in which case + // this request is rejected with 429. Honor Retry-After and retry once instead of failing. + let response = await request(); + if (response.status === 429) { + const retryAfterSeconds = Number(response.headers.get("retry-after")); + const waitMs = + Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 + ? Math.min(retryAfterSeconds * 1000, MAX_RETRY_AFTER_MS) + : DEFAULT_RETRY_AFTER_MS; + await abortableSleep(waitMs, signal, "Login cancelled"); + response = await request(); + } + if (!response.ok) { + const text = await response.text(); + throw new Error(`${response.status} ${response.statusText}: ${text}`); + } + return parseAvailableCopilotModelIds(await response.json(), allowPolicyFallback); } async function fetchJson(url: string, init: RequestInit): Promise { diff --git a/packages/ai/test/github-copilot-oauth.test.ts b/packages/ai/test/github-copilot-oauth.test.ts index 4094291a8..61ceea3e5 100644 --- a/packages/ai/test/github-copilot-oauth.test.ts +++ b/packages/ai/test/github-copilot-oauth.test.ts @@ -301,6 +301,65 @@ describe("GitHub Copilot OAuth device flow", () => { expect(maxActivePolicyRequests).toBe(1); }); + it("retries GET /models once after a 429, honoring Retry-After", async () => { + vi.useFakeTimers(); + + let modelsRequestCount = 0; + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = getUrl(input); + + if (url.endsWith("/login/device/code")) { + return jsonResponse({ + device_code: "device-code", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + interval: 1, + expires_in: 900, + }); + } + + if (url.endsWith("/login/oauth/access_token")) { + return jsonResponse({ access_token: "ghu_refresh_token" }); + } + + if (url.includes("/copilot_internal/v2/token")) { + return jsonResponse({ + token: "tid=test;exp=9999999999;proxy-ep=proxy.individual.githubcopilot.com;", + expires_at: 9999999999, + }); + } + + if (url.endsWith("/models")) { + modelsRequestCount += 1; + if (modelsRequestCount === 1) { + return new Response("too many requests", { status: 429, headers: { "retry-after": "1" } }); + } + return jsonResponse({ data: [{ id: "gpt-5.4", model_picker_enabled: true }] }); + } + + if (url.includes("/models/") && url.endsWith("/policy")) { + return new Response("", { status: 200 }); + } + + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const loginPromise = loginGitHubCopilotForTest({ + onDeviceCode: () => {}, + onPrompt: async () => "", + }); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1000); // device poll + await vi.advanceTimersByTimeAsync(1000); // Retry-After wait + const credentials = await loginPromise; + + expect(modelsRequestCount).toBe(2); + expect(credentials.availableModelIds).toEqual(["gpt-5.4"]); + }); + it("rejects a non-http(s) verification_uri before it reaches onDeviceCode", async () => { // A malicious enterprise OAuth server could return a verification_uri that // the browser launcher would otherwise hand to the OS. Ensure such values From 70e878d4cf8f643067c8fc73ea27b74133dca0ab Mon Sep 17 00:00:00 2001 From: Milosz Jankiewicz <25470423+Jaaneek@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:47:38 +0100 Subject: [PATCH 05/24] feat(ai): route xAI models through Responses and default to Grok 4.6 (#8124) * feat(ai): route xAI models through Responses and default to Grok 4.6 Send built-in xAI catalog models through the Responses API with store: false and include reasoning.encrypted_content so encrypted reasoning is requested and replayed, including future models.dev additions. Thinking levels come from models.dev reasoning_options (grok-4.6 exposes xhigh); models without verified options (grok-build-0.1) keep reasoning always on and never send unsupported "none"/"minimal" efforts. Narrow the xAI provider to the Responses API, send pi's runtime User-Agent on xAI requests for provider-side attribution, and make Grok 4.6 the default xAI model. * fix(ai): drop duplicate xAI encrypted-reasoning tests Azure already covers the shared replay path; keep the xAI-specific include-without-effort case next to the existing request tests. --------- Co-authored-by: Jaaneek --- packages/ai/README.md | 2 +- packages/ai/scripts/generate-models.ts | 20 +-- packages/ai/src/api/openai-completions.ts | 5 + packages/ai/src/api/openai-responses.ts | 5 + packages/ai/src/providers/xai.ts | 8 +- packages/ai/src/utils/pi-user-agent.ts | 8 + packages/ai/test/model-catalog-types.test.ts | 4 +- packages/ai/test/stream.test.ts | 2 +- packages/ai/test/supports-xhigh.test.ts | 6 + packages/ai/test/xai-responses.test.ts | 144 +++++++++++++++++- .../coding-agent/src/core/model-resolver.ts | 2 +- .../coding-agent/test/model-resolver.test.ts | 4 + 12 files changed, 185 insertions(+), 25 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index 16b3be096..e010fdf25 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -794,7 +794,7 @@ Many models support thinking/reasoning capabilities where they can show their in const model = models.getModel('anthropic', 'claude-sonnet-4-5')!; // or models.getModel('openai', 'gpt-5-mini'); // or models.getModel('google', 'gemini-2.5-flash'); -// or models.getModel('xai', 'grok-4.5'); +// or models.getModel('xai', 'grok-4.6'); // Check if model supports reasoning if (model.reasoning) { diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index e36d5551b..1f3a49bcb 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -410,7 +410,6 @@ const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.6-terra", "gpt-5.6-luna", ]); -const XAI_RESPONSES_MODEL_ID = "grok-4.5"; const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ "grok-3", "grok-3-fast", @@ -418,10 +417,6 @@ const XAI_BUILTIN_EXCLUDED_MODEL_IDS = new Set([ "grok-4.20-0309-reasoning", "grok-code-fast-1", ]); -const XAI_RESPONSES_EFFORT_LEVEL_MAP = { - off: null, - minimal: null, -} as const; const XAI_RESPONSES_COMPAT: OpenAIResponsesCompat = { supportsLongCacheRetention: false, }; @@ -823,8 +818,10 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { off: "none" }); } - if (model.provider === "xai" && model.api === "openai-responses" && model.id === XAI_RESPONSES_MODEL_ID) { - mergeThinkingLevelMap(model, XAI_RESPONSES_EFFORT_LEVEL_MAP); + // xAI models without verified effort options (e.g. grok-build-0.1) must not + // send the undocumented "none"/"minimal" efforts. + if (model.provider === "xai" && model.api === "openai-responses" && model.thinkingLevelMap === undefined) { + mergeThinkingLevelMap(model, { off: null, minimal: null }); } if (supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); @@ -1632,15 +1629,14 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data.xai.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; - const useResponsesApi = modelId === XAI_RESPONSES_MODEL_ID; models.push({ id: modelId, name: m.name || modelId, - api: useResponsesApi ? "openai-responses" : "openai-completions", + api: "openai-responses", provider: "xai", baseUrl: "https://api.x.ai/v1", - ...(useResponsesApi ? { compat: { ...XAI_RESPONSES_COMPAT } } : {}), + compat: { ...XAI_RESPONSES_COMPAT }, reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { @@ -1946,10 +1942,10 @@ async function loadModelsDevData(): Promise[]> { // Claude 4.x and 5.x models route to Anthropic Messages API const isCopilotClaude = /^claude-(haiku|sonnet|opus)-[45]([.\-]|$)/.test(modelId); - // Grok 4.5, gpt-5, oswe, and MAI-Code models are only served through + // Grok, gpt-5, oswe, and MAI-Code models are only served through // the Copilot /responses endpoint. const needsResponsesApi = - modelId === "grok-4.5" || + modelId.startsWith("grok-") || modelId.startsWith("gpt-5") || modelId.startsWith("oswe") || modelId.startsWith("mai-"); diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index c62ef9f87..268e4f4f8 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -39,6 +39,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; +import { forcePiUserAgent } 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"; @@ -670,6 +671,10 @@ function createClient( Object.assign(headers, optionsHeaders); } + if (model.provider === "xai") { + forcePiUserAgent(headers); + } + return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 578398e8e..58d179c7c 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -19,6 +19,7 @@ import { splitDeferredTools } from "../utils/deferred-tools.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; +import { forcePiUserAgent } from "../utils/pi-user-agent.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { createGrammarToolInputProperties } from "./constrained-sampling.ts"; @@ -246,6 +247,10 @@ function createClient( Object.assign(headers, optionsHeaders); } + if (model.provider === "xai") { + forcePiUserAgent(headers); + } + return new OpenAI({ apiKey, baseURL: model.baseUrl, diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index c9fe6c349..8e2b179c2 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,11 +1,10 @@ -import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; import { loadXaiOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import { XAI_MODELS } from "./xai.models.ts"; -export function xaiProvider(): Provider<"openai-completions" | "openai-responses"> { +export function xaiProvider(): Provider<"openai-responses"> { return createProvider({ id: "xai", name: "xAI", @@ -20,9 +19,6 @@ export function xaiProvider(): Provider<"openai-completions" | "openai-responses }), }, models: Object.values(XAI_MODELS), - api: { - "openai-completions": openAICompletionsApi(), - "openai-responses": openAIResponsesApi(), - }, + api: openAIResponsesApi(), }); } diff --git a/packages/ai/src/utils/pi-user-agent.ts b/packages/ai/src/utils/pi-user-agent.ts index 93b23dd35..c36d5ffbc 100644 --- a/packages/ai/src/utils/pi-user-agent.ts +++ b/packages/ai/src/utils/pi-user-agent.ts @@ -1,4 +1,5 @@ import type * as NodeOs from "node:os"; +import type { ProviderHeaders } from "../types.ts"; type ProcessWithOsBuiltinModule = typeof process & { getBuiltinModule?: (id: "node:os") => typeof NodeOs; @@ -17,3 +18,10 @@ const nodeOs = loadNodeOs(); export function getPiUserAgent(): string { return nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; } + +export function forcePiUserAgent(headers: ProviderHeaders): void { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === "user-agent") delete headers[name]; + } + headers["User-Agent"] = getPiUserAgent(); +} diff --git a/packages/ai/test/model-catalog-types.test.ts b/packages/ai/test/model-catalog-types.test.ts index 0facda3db..0918228fe 100644 --- a/packages/ai/test/model-catalog-types.test.ts +++ b/packages/ai/test/model-catalog-types.test.ts @@ -6,7 +6,9 @@ it("derives model API, ID, and provider literals from grouped model data", () => expectTypeOf(XAI_MODELS["grok-4.5"].api).toEqualTypeOf<"openai-responses">(); expectTypeOf(XAI_MODELS["grok-4.5"].id).toEqualTypeOf<"grok-4.5">(); expectTypeOf(XAI_MODELS["grok-4.5"].provider).toEqualTypeOf<"xai">(); - expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-completions">(); + expectTypeOf(XAI_MODELS["grok-4.6"].api).toEqualTypeOf<"openai-responses">(); + expectTypeOf(XAI_MODELS["grok-4.6"].id).toEqualTypeOf<"grok-4.6">(); + expectTypeOf(XAI_MODELS["grok-4.3"].api).toEqualTypeOf<"openai-responses">(); }); it("routes GitHub Copilot Grok 4.5 through the Responses API", () => { diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index d3e7a0211..bf68823d2 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -542,7 +542,7 @@ describe("Generate E2E Tests", () => { }); }); - describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Completions)", () => { + describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider (grok-4.3 via OpenAI Responses)", () => { const llm = getModel("xai", "grok-4.3"); it("should complete basic text generation", { retry: 3 }, async () => { diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index a1fd7ff8f..8d97b7607 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -147,6 +147,12 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toContain("max"); }); + it("includes xhigh but not off or max for xAI Grok 4.6", () => { + const model = getModel("xai", "grok-4.6"); + expect(model).toBeDefined(); + expect(getSupportedThinkingLevels(model!)).toEqual(["low", "medium", "high", "xhigh"]); + }); + it("includes xhigh and max but not off for Bedrock Claude Fable 5", () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5"); expect(model).toBeDefined(); diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts index 92e464203..00e890815 100644 --- a/packages/ai/test/xai-responses.test.ts +++ b/packages/ai/test/xai-responses.test.ts @@ -1,10 +1,15 @@ +import { arch, platform, release } from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts"; import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts"; +import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts"; import { getSupportedThinkingLevels } from "../src/models.ts"; import { XAI_MODELS } from "../src/providers/xai.models.ts"; import { xaiProvider } from "../src/providers/xai.ts"; import type { Context, Model } from "../src/types.ts"; +const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`; + type CapturedRequest = { url: string; headers: Headers; @@ -72,10 +77,14 @@ describe("xAI Responses provider", () => { } }); - it("uses Responses with low/medium/high efforts only for Grok 4.5", () => { - expect(XAI_MODELS["grok-4.5"].api).toBe("openai-responses"); + it("routes every built-in xAI model through Responses", () => { + for (const model of Object.values(XAI_MODELS)) { + expect(model.api, model.id).toBe("openai-responses"); + } expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]); - expect(XAI_MODELS["grok-4.3"].api).toBe("openai-completions"); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.6"])).toEqual(["low", "medium", "high", "xhigh"]); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.3"])).toEqual(["off", "low", "medium", "high"]); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-build-0.1"])).toEqual(["low", "medium", "high"]); }); it("uses /responses with bearer auth and xAI-compatible request fields", async () => { @@ -95,6 +104,7 @@ describe("xAI Responses provider", () => { expect(captured.url).toBe("https://api.x.ai/v1/responses"); expect(captured.headers.get("authorization")).toBe("Bearer xai-test-token"); + expect(captured.headers.get("user-agent")).toBe(PI_USER_AGENT); expect(captured.headers.get("session_id")).toBe("pi-session-123"); expect(captured.body).toMatchObject({ model: "grok-4.5", @@ -114,4 +124,132 @@ describe("xAI Responses provider", () => { ]), ); }); + + it("requests encrypted reasoning without an effort override", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.5"], + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token" }, + ); + + expect(captured.body).toMatchObject({ + model: "grok-4.5", + store: false, + include: ["reasoning.encrypted_content"], + }); + expect(captured.body).not.toHaveProperty("reasoning"); + }); + + it("uses /responses for Grok 4.6 with xhigh effort and encrypted reasoning", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.6"], + { + systemPrompt: "You are a careful coding assistant.", + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + reasoningEffort: "xhigh", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.body).toMatchObject({ + model: "grok-4.6", + store: false, + stream: true, + reasoning: { effort: "xhigh" }, + include: ["reasoning.encrypted_content"], + }); + }); + + it("uses /responses for Grok 4.3", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.3"], + { + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + reasoningEffort: "low", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.body).toMatchObject({ + model: "grok-4.3", + store: false, + include: ["reasoning.encrypted_content"], + reasoning: { effort: "low" }, + }); + }); + + it("keeps the SDK User-Agent for non-xAI Responses requests", async () => { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + return completedResponse(); + }); + + const openaiModel: Model<"openai-responses"> = { + ...XAI_MODELS["grok-4.5"], + provider: "openai", + baseUrl: "https://api.openai.com/v1", + }; + const result = await streamOpenAIResponses( + openaiModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "test-token" }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(userAgent).not.toBeNull(); + expect(userAgent).not.toBe(PI_USER_AGENT); + }); + + it("forces pi's User-Agent on custom xAI Completions models over caller headers", async () => { + let userAgent: string | null = null; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + userAgent = new Request(input, init).headers.get("user-agent"); + const chunks = [ + { id: "chatcmpl-ua", choices: [{ delta: { content: "ok" }, finish_reason: null, index: 0 }] }, + { + id: "chatcmpl-ua", + choices: [{ delta: {}, finish_reason: "stop", index: 0 }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n")}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }); + + const customModel: Model<"openai-completions"> = { + id: "grok-custom", + name: "Grok Custom", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }; + const result = await streamOpenAICompletions( + customModel, + { messages: [{ role: "user", content: "hello", timestamp: 1 }] }, + { apiKey: "xai-test-token", headers: { "User-Agent": "custom-agent" } }, + ).result(); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(userAgent).toBe(PI_USER_AGENT); + }); }); diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index cfc36ec16..aea6717e1 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -32,7 +32,7 @@ export const defaultModelPerProvider: Record = { "github-copilot": "gpt-5.4", openrouter: "moonshotai/kimi-k2.6", "vercel-ai-gateway": "zai/glm-5.1", - xai: "grok-4.5", + xai: "grok-4.6", groq: "openai/gpt-oss-120b", cerebras: "zai-glm-4.7", zai: "glm-5.3", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index ddf83c569..c38b59f04 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -723,6 +723,10 @@ describe("default model selection", () => { expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("zai/glm-5.1"); }); + test("xai default tracks current model", () => { + expect(defaultModelPerProvider.xai).toBe("grok-4.6"); + }); + test("qwen token plan individual default tracks current model", () => { expect(defaultModelPerProvider["qwen-token-plan-individual"]).toBe("qwen3.8-max"); }); From d3ab2af969d64997338253c9151190aa1bc33580 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:48:06 +0200 Subject: [PATCH 06/24] fix: track kimi cached tokens (#8119) * fix: track kimi cached tokens * changelog --- packages/ai/CHANGELOG.md | 4 ++++ packages/ai/src/api/openai-completions.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 182e1d8a3..bfe0c60cf 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). + ## [0.84.2] - 2026-08-14 ### Added diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 268e4f4f8..fd58a2284 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1381,6 +1381,7 @@ function parseChunkUsage( rawUsage: { prompt_tokens?: number; completion_tokens?: number; + cached_tokens?: number; prompt_cache_hit_tokens?: number; prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number }; completion_tokens_details?: { reasoning_tokens?: number }; @@ -1388,11 +1389,15 @@ function parseChunkUsage( model: Model<"openai-completions">, ): AssistantMessage["usage"] { const promptTokens = rawUsage.prompt_tokens || 0; - const cacheReadTokens = rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? 0; + const cacheReadTokens = + rawUsage.prompt_tokens_details?.cached_tokens ?? rawUsage.prompt_cache_hit_tokens ?? rawUsage.cached_tokens ?? 0; const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0; // Follow documented OpenAI/OpenRouter semantics: cached_tokens is cache-read - // tokens (hits). OpenAI does not document or emit cache_write_tokens, but + // tokens (hits). Providers disagree on placement: OpenAI/OpenRouter use + // prompt_tokens_details.cached_tokens, DeepSeek uses prompt_cache_hit_tokens, + // and Kimi documents top-level usage.cached_tokens on the final usage chunk. + // OpenAI does not document or emit cache_write_tokens, but // OpenRouter-compatible providers can include it as a separate write count. // OpenRouter's own provider/tests affirm the separate mapping: // https://github.com/OpenRouterTeam/ai-sdk-provider/pull/409 From 47bf47f11f1379b142574dfeacf762981989add4 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 07:10:50 +0200 Subject: [PATCH 07/24] docs(coding-agent): clarify compaction paths --- .../coding-agent/src/core/agent-session.ts | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1a1228d00..77d8b8764 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1784,7 +1784,17 @@ export class AgentSession { /** * Manually compact the session context. - * Aborts current agent operation first. + * + * This is the manual entry point used by `/compact`, RPC, and extensions. It is + * separate from automatic threshold/overflow compaction, which enters through + * `_checkCompaction()` and `_runAutoCompaction()`. After preparation and the + * `session_before_compact` hook, both paths call the lower-level `compact()` + * function imported from `./compaction/index.ts`, unless the hook cancels or + * supplies a custom result. + * + * Aborts the current agent operation first. Manual compaction never retries or + * continues the interrupted agent turn. + * * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { @@ -1850,7 +1860,7 @@ export class AgentSession { usage = extensionCompaction.usage; details = extensionCompaction.details; } else { - // Generate compaction result + // Shared default summary generator, also used by automatic compaction. const result = await compact( preparation, requestModel, @@ -1948,16 +1958,25 @@ export class AgentSession { } /** - * Check if compaction is needed and run it. - * Called after agent_end and before prompt submission. + * Dispatch automatic compaction after `agent_end` or before prompt submission. + * Manual compaction does not call this method; it enters through `compact()`. * - * Two cases: - * 1. Recoverable failure: LLM returned context overflow or stopped below its desired output limit; - * remove the assistant message, compact, and auto-retry once - * 2. Threshold: Context over threshold, compact, NO auto-retry (user continues manually) + * Automatic cases: + * 1. Overflow with retry: a context-overflow error or recoverable length stop; + * remove the failed assistant message, compact, and retry the turn once. + * 2. Overflow without retry: a successful response exceeded the configured + * context window; compact but preserve the completed response. + * 3. Threshold without retry: valid or estimated context usage crossed the + * configured threshold; compact without retrying the completed response. + * + * Each case calls `_runAutoCompaction()`. After preparation and the + * `session_before_compact` hook, that method calls the lower-level `compact()` + * function imported from `./compaction/index.ts`, unless the hook cancels or + * supplies a custom result. * * @param assistantMessage The assistant message to check * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true + * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages */ private async _checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise { const settings = this.settingsManager.getCompactionSettings(); @@ -1985,15 +2004,15 @@ export class AgentSession { return false; } - // Case 1: Recoverable failure. Explicit/silent context overflow still uses context metadata. + // Automatic cases 1 and 2: context overflow. // A length stop is recoverable when output ended below the model's original desired limit, // independent of the configured context size or any context-clamped provider request limit. - // A successful response over the configured window should compact but must not retry: the - // assistant answer already completed and agent.continue() cannot continue from an assistant. const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0); if (sameModel && (isContextOverflow(assistantMessage, contextWindow) || recoverableLength)) { const willRetry = assistantMessage.stopReason !== "stop"; + // Case 2: the response completed successfully. Compact, but do not retry because + // agent.continue() cannot continue from a completed assistant response. if (!willRetry) { return await this._runAutoCompaction("overflow", false); } @@ -2011,9 +2030,9 @@ export class AgentSession { return false; } + // Case 1: remove the failed or truncated message from agent state, compact, and + // retry once. The message remains in session history but is excluded from retry context. this._overflowRecoveryAttempted = true; - // Remove the failed or truncated message from agent state. It remains in session history, - // but must not be included in the compact-and-retry context. const messages = this.agent.state.messages; if (messages.length > 0 && messages[messages.length - 1].role === "assistant") { this.agent.state.messages = messages.slice(0, -1); @@ -2021,7 +2040,7 @@ export class AgentSession { return await this._runAutoCompaction("overflow", willRetry); } - // Case 2: Threshold - context is getting large + // Case 3: threshold compaction without retry. // For error messages or all-zero usage messages, estimate from the last valid response. // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage // responses can still compact and do not reset context accounting. @@ -2053,7 +2072,14 @@ export class AgentSession { } /** - * Internal: Run auto-compaction with events. + * Execute threshold or overflow compaction. Manual compaction uses + * `AgentSession.compact()` instead. Both paths call the lower-level `compact()` + * function imported from `./compaction/index.ts` after preparation and extension + * interception. + * + * @param reason Automatic trigger selected by `_checkCompaction()` + * @param willRetry Whether to continue the interrupted turn after overflow compaction + * @returns Whether the post-run loop should call `agent.continue()` */ private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); @@ -2122,7 +2148,7 @@ export class AgentSession { usage = extensionCompaction.usage; details = extensionCompaction.details; } else { - // Generate compaction result + // Shared default summary generator, also used by manual compaction. const compactResult = await compact( preparation, requestModel, From 58302d34e703e0453ea13bdd10c7e423589ce177 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 07:34:52 +0200 Subject: [PATCH 08/24] feat(coding-agent): support compaction routing sessions --- .../coding-agent/src/core/agent-session.ts | 2 ++ .../src/core/compaction/compaction.ts | 30 +++++++++++++++---- .../test/compaction-summary-reasoning.test.ts | 14 +++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 77d8b8764..f0dae74f1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1873,6 +1873,7 @@ export class AgentSession { env, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }), + undefined, // sessionId ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; @@ -2161,6 +2162,7 @@ export class AgentSession { env, this.settingsManager.getRetrySettings(), this._summarizationRetryCallbacks({ source: "compaction", reason }), + undefined, // sessionId ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 3f9b90ca4..ee121052e 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -544,8 +544,9 @@ function createSummarizationOptions( env: Record | undefined, signal: AbortSignal | undefined, thinkingLevel: ThinkingLevel | undefined, + sessionId: string | undefined, ): SimpleStreamOptions { - const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env }; + const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers, env, sessionId }; if (model.reasoning && thinkingLevel && thinkingLevel !== "off") { options.reasoning = thinkingLevel; } @@ -567,11 +568,12 @@ export async function completeSummarization( retry?: RetryPolicy, callbacks?: RetryCallbacks, ): Promise { - // Summaries are standalone requests, so isolate routing and avoid cache writes that cannot be reused. + // Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available; + // callers without a session ID, including branch summaries, receive a fresh routing ID. const requestOptions: SimpleStreamOptions = { ...options, cacheRetention: "none", - sessionId: uuidv7(), + sessionId: options.sessionId ?? uuidv7(), }; const produce = async (): Promise => streamFn @@ -598,6 +600,7 @@ export async function generateSummary( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise { return ( await generateSummaryWithUsage( @@ -614,6 +617,7 @@ export async function generateSummary( env, retry, callbacks, + sessionId, ) ).text; } @@ -633,6 +637,7 @@ export async function generateSummaryWithUsage( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), @@ -665,7 +670,16 @@ export async function generateSummaryWithUsage( }, ]; - const completionOptions = createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel); + const completionOptions = createSummarizationOptions( + model, + maxTokens, + apiKey, + headers, + env, + signal, + thinkingLevel, + sessionId, + ); const response = await completeSummarization( model, @@ -813,6 +827,7 @@ Be concise. Focus on what's needed to understand the kept suffix.`; * * @param preparation - Pre-calculated preparation from prepareCompaction() * @param customInstructions - Optional custom focus for the summary + * @param sessionId - Optional routing session ID forwarded without enabling prompt caching */ export async function compact( preparation: CompactionPreparation, @@ -826,6 +841,7 @@ export async function compact( env?: Record, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise { const { firstKeptEntryId, @@ -860,6 +876,7 @@ export async function compact( env, retry, callbacks, + sessionId, ); historyText = historyResult.text; historyUsage = historyResult.usage; @@ -876,6 +893,7 @@ export async function compact( streamFn, retry, callbacks, + sessionId, ); // Merge into single summary summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; @@ -896,6 +914,7 @@ export async function compact( env, retry, callbacks, + sessionId, ); summary = result.text; summaryUsage = result.usage; @@ -933,6 +952,7 @@ async function generateTurnPrefixSummary( streamFn?: StreamFn, retry?: RetryPolicy, callbacks?: RetryCallbacks, + sessionId?: string, ): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), @@ -952,7 +972,7 @@ async function generateTurnPrefixSummary( const response = await completeSummarization( model, { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages }, - createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel), + createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel, sessionId), streamFn, retry, callbacks, diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index e79a55047..7259edf9f 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { type CompactionPreparation, compact, + completeSummarization, generateSummary, generateSummaryWithUsage, } from "../src/core/compaction/index.ts"; @@ -102,6 +103,19 @@ describe("generateSummary reasoning options", () => { expect(sessionIds[0]).not.toBe(sessionIds[1]); }); + it("honors a caller-supplied routing session without prompt caching", async () => { + await completeSummarization( + createModel(false), + { systemPrompt: "Summarize", messages: [] }, + { sessionId: "current-routing-session", cacheRetention: "long" }, + ); + + expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ + sessionId: "current-routing-session", + cacheRetention: "none", + }); + }); + it("does not set reasoning when thinking is off", async () => { await generateSummary( messages, From ca21c1686157bf29696c9252f623fc094447ddf2 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:36:17 +0200 Subject: [PATCH 09/24] fix: single edit input (#8011) * fix: single edit input * changelog go brrrr --- packages/agent/src/harness/tools/edit.ts | 15 ++++++++++++- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/tools/edit.ts | 22 ++++++++++++++++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/harness/tools/edit.ts b/packages/agent/src/harness/tools/edit.ts index c8210ad70..5473c48b8 100644 --- a/packages/agent/src/harness/tools/edit.ts +++ b/packages/agent/src/harness/tools/edit.ts @@ -38,6 +38,13 @@ const editSchema = Type.Object( export type EditToolInput = Static; type LegacyEditToolInput = EditToolInput & { oldText?: unknown; newText?: unknown }; +type SingleEditInput = { oldText: string; newText: string }; + +function isSingleEditInput(value: unknown): value is SingleEditInput { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const edit = value as Record; + return typeof edit.oldText === "string" && typeof edit.newText === "string"; +} export interface EditToolDetails { diff: string; @@ -51,8 +58,14 @@ function prepareEditArguments(input: unknown): EditToolInput { if (typeof args.edits === "string") { try { const parsed: unknown = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; + if (Array.isArray(parsed)) { + args.edits = parsed; + } else if (isSingleEditInput(parsed)) { + args.edits = [parsed]; + } } catch {} + } else if (isSingleEditInput(args.edits)) { + args.edits = [args.edits]; } const legacy = args as LegacyEditToolInput; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 934b09c73..d35b19c5a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,6 +36,7 @@ ### Fixed +- Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays in both coding-agent and harness edit tools ([#7835](https://github.com/earendil-works/pi/issues/7835)). - 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)). diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 45281c254..149a2e157 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -69,6 +69,17 @@ type LegacyEditToolInput = EditToolInput & { newText?: unknown; }; +type SingleEditInput = { oldText: string; newText: string }; + +function isSingleEditInput(value: unknown): value is SingleEditInput { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const edit = value as Record; + return typeof edit.oldText === "string" && typeof edit.newText === "string"; +} + export interface EditToolDetails { /** Display-oriented diff of the changes made */ diff: string; @@ -109,12 +120,19 @@ function prepareEditArguments(input: unknown): EditToolInput { const args = input as Record; - // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array + // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array. + // Others send a single edit object instead of a one-element edits array. if (typeof args.edits === "string") { try { const parsed = JSON.parse(args.edits); - if (Array.isArray(parsed)) args.edits = parsed; + if (Array.isArray(parsed)) { + args.edits = parsed; + } else if (isSingleEditInput(parsed)) { + args.edits = [parsed]; + } } catch {} + } else if (isSingleEditInput(args.edits)) { + args.edits = [args.edits]; } const legacy = args as LegacyEditToolInput; From 8c2529daebe0eac5aecb54424b607b4c88d55e15 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:37:37 +0200 Subject: [PATCH 10/24] fix: dont load root mds as skills in settings (#8012) * fix: dont load root mds as skills in settings * changelogololol --------- Co-authored-by: Mario Zechner --- packages/agent/src/harness/skills.ts | 17 +++- packages/agent/test/harness/skills.test.ts | 19 +++++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/skills.md | 5 +- packages/coding-agent/src/core/skills.ts | 98 +++++++++++++--------- 5 files changed, 96 insertions(+), 44 deletions(-) diff --git a/packages/agent/src/harness/skills.ts b/packages/agent/src/harness/skills.ts index 0c0b54330..44fd828ed 100644 --- a/packages/agent/src/harness/skills.ts +++ b/packages/agent/src/harness/skills.ts @@ -43,8 +43,9 @@ export function formatSkillInvocation(skill: Skill, additionalInstructions?: str /** * Load skills from one or more directories. * - * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files as skills, honors ignore files, - * and returns diagnostics for invalid skill files. Missing input directories are skipped. + * Traverses directories recursively, loads `SKILL.md` files, loads direct root `.md` files with skill + * frontmatter, honors ignore files, and returns diagnostics for invalid declared skill files. Missing input + * directories are skipped. */ export async function loadSkills( env: ExecutionEnv, @@ -246,6 +247,11 @@ async function loadSkillFromFile( parentDirName: string, ): Promise<{ skill: Skill | null; diagnostics: SkillDiagnostic[] }> { const diagnostics: SkillDiagnostic[] = []; + const isDeclaredSkill = + filePath + .replace(/[\\/]+$/, "") + .split(/[\\/]/) + .pop() === "SKILL.md"; const rawContent = await env.readTextFile(filePath); if (!rawContent.ok) { diagnostics.push({ type: "warning", code: "read_failed", message: rawContent.error.message, path: filePath }); @@ -254,12 +260,17 @@ async function loadSkillFromFile( const parsed = parseFrontmatter(rawContent.value); if (!parsed.ok) { - diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + if (isDeclaredSkill) { + diagnostics.push({ type: "warning", code: "parse_failed", message: parsed.error.message, path: filePath }); + } return { skill: null, diagnostics }; } const { frontmatter, body } = parsed.value; const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined; + if (!isDeclaredSkill && (!description || description.trim() === "")) { + return { skill: null, diagnostics }; + } for (const error of validateDescription(description)) { diagnostics.push({ type: "warning", code: "invalid_metadata", message: error, path: filePath }); diff --git a/packages/agent/test/harness/skills.test.ts b/packages/agent/test/harness/skills.test.ts index bd769ea91..88c155948 100644 --- a/packages/agent/test/harness/skills.test.ts +++ b/packages/agent/test/harness/skills.test.ts @@ -113,4 +113,23 @@ Use this skill. expect(skills.map((skill) => skill.name)).toEqual(["skills"]); expect(skills[0]?.content).toBe("Root content"); }); + + it("ignores root markdown docs that do not declare skills", async () => { + const root = createTempDir(); + const env = new NodeExecutionEnv({ cwd: root }); + await env.createDir("skills/nested-skill", { recursive: true }); + await env.writeFile("skills/README.md", "# Shared skills\n\nDocumentation."); + await env.writeFile("skills/AGENTS.md", "# Agent notes\n\nDocumentation."); + await env.writeFile("skills/CLAUDE.md", "---\ndescription: [invalid\n---\n\nDocumentation."); + await env.writeFile("skills/root.md", "---\ndescription: Root skill\n---\nRoot content"); + await env.writeFile( + "skills/nested-skill/SKILL.md", + "---\nname: nested-skill\ndescription: Nested skill\n---\nNested content", + ); + + const { skills, diagnostics } = await loadSkills(env, "skills"); + + expect(diagnostics).toEqual([]); + expect(skills.map((skill) => skill.name).sort()).toEqual(["nested-skill", "skills"]); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d35b19c5a..eb7f6433c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -36,6 +36,7 @@ ### Fixed +- Fixed root Markdown files such as `README.md` and `AGENTS.md` in skill directories being reported as broken skills unless they declare valid skill frontmatter ([#7805](https://github.com/earendil-works/pi/issues/7805)). - Fixed single-object `edit` tool inputs failing validation by accepting them as one-edit arrays in both coding-agent and harness edit tools ([#7835](https://github.com/earendil-works/pi/issues/7835)). - 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. diff --git a/packages/coding-agent/docs/skills.md b/packages/coding-agent/docs/skills.md index d3ffeea89..198a6c17c 100644 --- a/packages/coding-agent/docs/skills.md +++ b/packages/coding-agent/docs/skills.md @@ -34,9 +34,10 @@ Pi loads skills from: - CLI: `--skill ` (repeatable, additive even with `--no-skills`) Discovery rules: -- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills +- In `~/.pi/agent/skills/` and `.pi/skills/`, direct root `.md` files are discovered as individual skills when they have valid skill frontmatter with a non-empty `description` - In all skill locations, directories containing `SKILL.md` are discovered recursively - In `~/.agents/skills/` and project `.agents/skills/`, root `.md` files are ignored +- Root Markdown files other than `SKILL.md` that do not look like skills are ignored silently Disable discovery with `--no-skills` (explicit `--skill` paths still load). @@ -183,7 +184,7 @@ Pi validates skills against the Agent Skills standard. Most issues produce warni Unknown frontmatter fields are ignored. -**Exception:** Skills with missing description are not loaded. +Declared skills with missing descriptions are not loaded. Malformed `SKILL.md` files and `SKILL.md` files without a description produce warnings and are not loaded. Other Markdown files without valid skill frontmatter are ignored. Name collisions (same name from different locations) warn and keep the first skill found. diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index c104c8564..464129b64 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -114,10 +114,10 @@ function validateName(name: string): string[] { /** * Validate description per Agent Skills spec. */ -function validateDescription(description: string | undefined): string[] { +function validateDescription(description: unknown): string[] { const errors: string[] = []; - if (!description || description.trim() === "") { + if (typeof description !== "string" || description.trim() === "") { errors.push("description is required"); } else if (description.length > MAX_DESCRIPTION_LENGTH) { errors.push(`description exceeds ${MAX_DESCRIPTION_LENGTH} characters (${description.length})`); @@ -279,49 +279,69 @@ function loadSkillFromFile( source: string, ): { skill: Skill | null; diagnostics: ResourceDiagnostic[] } { const diagnostics: ResourceDiagnostic[] = []; + const isDeclaredSkill = basename(filePath) === "SKILL.md"; + let rawContent: string; try { - const rawContent = readFileSync(filePath, "utf-8"); - const { frontmatter } = parseFrontmatter(rawContent); - const skillDir = dirname(filePath); - const parentDirName = basename(skillDir); - - // Validate description - const descErrors = validateDescription(frontmatter.description); - for (const error of descErrors) { - diagnostics.push({ type: "warning", message: error, path: filePath }); - } - - // Use name from frontmatter, or fall back to parent directory name - const name = frontmatter.name || parentDirName; - - // Validate name - const nameErrors = validateName(name); - for (const error of nameErrors) { - diagnostics.push({ type: "warning", message: error, path: filePath }); - } - - // Still load the skill even with warnings (unless description is completely missing) - if (!frontmatter.description || frontmatter.description.trim() === "") { - return { skill: null, diagnostics }; - } - - return { - skill: { - name, - description: frontmatter.description, - filePath, - baseDir: skillDir, - sourceInfo: createSkillSourceInfo(filePath, skillDir, source), - disableModelInvocation: frontmatter["disable-model-invocation"] === true, - }, - diagnostics, - }; + rawContent = readFileSync(filePath, "utf-8"); } catch (error) { - const message = error instanceof Error ? error.message : "failed to parse skill file"; + const message = error instanceof Error ? error.message : "failed to read skill file"; diagnostics.push({ type: "warning", message, path: filePath }); return { skill: null, diagnostics }; } + + let frontmatter: SkillFrontmatter; + try { + ({ frontmatter } = parseFrontmatter(rawContent)); + } catch (error) { + if (isDeclaredSkill) { + const message = error instanceof Error ? error.message : "failed to parse skill file"; + diagnostics.push({ type: "warning", message, path: filePath }); + } + return { skill: null, diagnostics }; + } + + const description = frontmatter.description; + const hasDescription = typeof description === "string" && description.trim() !== ""; + if (!isDeclaredSkill && !hasDescription) { + return { skill: null, diagnostics }; + } + + const skillDir = dirname(filePath); + const parentDirName = basename(skillDir); + + // Validate description + const descErrors = validateDescription(description); + for (const error of descErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + // Use name from frontmatter, or fall back to parent directory name + const frontmatterName = typeof frontmatter.name === "string" ? frontmatter.name : undefined; + const name = frontmatterName || parentDirName; + + // Validate name + const nameErrors = validateName(name); + for (const error of nameErrors) { + diagnostics.push({ type: "warning", message: error, path: filePath }); + } + + // Still load the skill even with warnings, unless description is missing or empty. + if (!hasDescription) { + return { skill: null, diagnostics }; + } + + return { + skill: { + name, + description, + filePath, + baseDir: skillDir, + sourceInfo: createSkillSourceInfo(filePath, skillDir, source), + disableModelInvocation: frontmatter["disable-model-invocation"] === true, + }, + diagnostics, + }; } /** From f47faf459f1a88ead70c3033b70931217251b4f3 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:42:51 +0200 Subject: [PATCH 11/24] fix: register flag type mismatch (#8123) --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/extensions/loader.ts | 5 +++++ .../coding-agent/src/core/extensions/types.ts | 16 ++++++++++----- .../test/extensions-runner.test.ts | 20 +++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index eb7f6433c..1aa972632 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index a16c78530..74d38f12c 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -295,6 +295,11 @@ function createExtensionAPI( options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, ): void { runtime.assertActive(); + if (options.default !== undefined && typeof options.default !== options.type) { + throw new Error( + `Invalid default for flag "${name}": expected ${options.type}, got ${typeof options.default}`, + ); + } extension.flags.set(name, { name, extensionPath: extension.path, ...options }); if (options.default !== undefined && !runtime.flagValues.has(name)) { runtime.flagValues.set(name, options.default); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index db0ebacaa..0b8428d0e 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1271,11 +1271,17 @@ export interface ExtensionAPI { /** Register a CLI flag. */ registerFlag( name: string, - options: { - description?: string; - type: "boolean" | "string"; - default?: boolean | string; - }, + options: + | { + description?: string; + type: "boolean"; + default?: boolean; + } + | { + description?: string; + type: "string"; + default?: string; + }, ): void; /** Get the value of a registered CLI flag. */ diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index f58a4da62..70f06a3cf 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -691,6 +691,26 @@ describe("ExtensionRunner", () => { expect(result.runtime.flagValues.get("shared-flag")).toBe(true); }); + it("rejects default values that do not match the flag type", async () => { + const extCode = ` + export default function(pi) { + pi.registerFlag("safe-mode", { + type: "boolean", + default: "false", + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "bad-flag-default.ts"), extCode); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + + expect(result.extensions).toHaveLength(0); + expect(result.errors[0]?.error).toContain( + 'Invalid default for flag "safe-mode": expected boolean, got string', + ); + expect(result.runtime.flagValues.has("safe-mode")).toBe(false); + }); + it("can set flag values", async () => { const extCode = ` export default function(pi) { From 86d001d36b898283ef7d63b1df31709e9730c452 Mon Sep 17 00:00:00 2001 From: Poison Date: Mon, 17 Aug 2026 16:41:39 +0800 Subject: [PATCH 12/24] fix(ai): expose low thinking level for DeepSeek V4 Flash on opencode/opencode-go (#8181) DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP (which enables the low effort level) was only applied to deepseek/deepseek-v4-flash, so the same model served through opencode and opencode-go only offered off/high/max. models.dev reports reasoning_options effort values of low/high/max for these providers, and the live opencode-go API accepts reasoning_effort=low (verified against https://opencode.ai/zen/go/v1). Apply the flash map to every deepseek-v4-flash variant on the deepseek, opencode, and opencode-go providers. qwen-token-plan is left unchanged since its gateway support for the low effort level is not verified. --- packages/ai/scripts/generate-models.ts | 3 ++- packages/ai/test/supports-xhigh.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 1f3a49bcb..39c1f6da1 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -872,7 +872,8 @@ function applyThinkingLevelMetadata(model: Model): void { model, model.provider === "openrouter" ? { ...DEEPSEEK_V4_THINKING_LEVEL_MAP, xhigh: "xhigh", max: null } - : model.provider === "deepseek" && model.id === "deepseek-v4-flash" + : (model.provider === "deepseek" || model.provider === "opencode" || model.provider === "opencode-go") && + model.id.includes("deepseek-v4-flash") ? DEEPSEEK_V4_FLASH_THINKING_LEVEL_MAP : DEEPSEEK_V4_THINKING_LEVEL_MAP, ); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index 8d97b7607..e2e428a0a 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -88,10 +88,10 @@ describe("getSupportedThinkingLevels", () => { expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "high", "max"]); }); - it("includes only high/max plus off for DeepSeek V4 Flash on opencode-go", () => { + it("includes low/high/max plus off for DeepSeek V4 Flash on opencode-go", () => { const model = getModel("opencode-go", "deepseek-v4-flash"); expect(model).toBeDefined(); - expect(getSupportedThinkingLevels(model!)).toEqual(["off", "high", "max"]); + expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "high", "max"]); }); it("includes only high plus off for OpenCode Go Kimi K2.6", () => { From c7c763f5c48736fa00cdcf0bcbfcae5cbc585e7c Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 11:03:07 +0200 Subject: [PATCH 13/24] fix(coding-agent): clarify truncated recovery failure closes #8130 --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/agent-session.ts | 9 ++++-- .../suite/agent-session-compaction.test.ts | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1aa972632..5f433be9a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). +- Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index f0dae74f1..8e7a59e89 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -2008,8 +2008,9 @@ export class AgentSession { // Automatic cases 1 and 2: context overflow. // A length stop is recoverable when output ended below the model's original desired limit, // independent of the configured context size or any context-clamped provider request limit. + const contextOverflow = sameModel && isContextOverflow(assistantMessage, contextWindow); const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0); - if (sameModel && (isContextOverflow(assistantMessage, contextWindow) || recoverableLength)) { + if (contextOverflow || recoverableLength) { const willRetry = assistantMessage.stopReason !== "stop"; // Case 2: the response completed successfully. Compact, but do not retry because @@ -2019,14 +2020,16 @@ export class AgentSession { } if (this._overflowRecoveryAttempted) { + const errorMessage = contextOverflow + ? "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model." + : "Truncated response recovery failed after one compact-and-retry attempt."; this._emit({ type: "compaction_end", reason: "overflow", result: undefined, aborted: false, willRetry: false, - errorMessage: - "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", + errorMessage, }); return false; } diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index dbb512f37..6a21f7f9f 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -353,6 +353,34 @@ describe("AgentSession compaction characterization", () => { expect(harness.faux.state.callCount).toBe(2); expect(harness.eventsOfType("compaction_start").filter((event) => event.reason === "overflow")).toHaveLength(1); expect(harness.eventsOfType("compaction_end").at(-1)?.errorMessage).toBe( + "Truncated response recovery failed after one compact-and-retry attempt.", + ); + }); + + it("keeps overflow wording when a repeated length stop fills the context window", async () => { + const harness = await createHarness({ + models: [{ id: "faux-1", contextWindow: 100, maxTokens: 100 }], + }); + harnesses.push(harness); + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + const lengthOverflowMessage = createAssistant(harness, { + stopReason: "length", + totalTokens: 100, + timestamp: Date.now(), + }); + const runAutoCompactionSpy = vi.spyOn(sessionInternals, "_runAutoCompaction").mockResolvedValue(false); + const compactionErrors: string[] = []; + harness.session.subscribe((event) => { + if (event.type === "compaction_end" && event.errorMessage) { + compactionErrors.push(event.errorMessage); + } + }); + + await sessionInternals._checkCompaction(lengthOverflowMessage); + await sessionInternals._checkCompaction({ ...lengthOverflowMessage, timestamp: Date.now() + 1 }); + + expect(runAutoCompactionSpy).toHaveBeenCalledTimes(1); + expect(compactionErrors).toContain( "Context overflow recovery failed after one compact-and-retry attempt. Try reducing context or switching to a larger-context model.", ); }); From 955a543b31a5672ecae52f7919cf5d0a87959656 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:40:11 +0200 Subject: [PATCH 14/24] fix: expose sleeping llama.cpp models (#8235) --- .../coding-agent/src/extensions/llama/provider.ts | 9 +++++++-- .../coding-agent/test/llama-extension.test.ts | 15 +++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/extensions/llama/provider.ts b/packages/coding-agent/src/extensions/llama/provider.ts index 518007cb4..83fa72ddc 100644 --- a/packages/coding-agent/src/extensions/llama/provider.ts +++ b/packages/coding-agent/src/extensions/llama/provider.ts @@ -25,6 +25,11 @@ async function resolveServerUrl( return configured ? normalizeLlamaServerUrl(configured) : undefined; } +function modelIsSelectable(model: LlamaModelInfo): boolean { + // llama.cpp reports idle-slept models as "sleeping"; requests wake them automatically. + return model.status.value === "loaded" || model.status.value === "sleeping"; +} + function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-completions"> { const reportedContextWindow = model.meta?.n_ctx ?? model.meta?.n_ctx_train; const contextWindow = reportedContextWindow && reportedContextWindow > 0 ? reportedContextWindow : 128000; @@ -59,7 +64,7 @@ export function createLlamaProvider(): LlamaProviderController { let models: readonly Model<"openai-completions">[] = []; const setCatalog = (catalog: readonly LlamaModelInfo[], serverUrl: string): void => { - models = catalog.filter((model) => model.status.value === "loaded").map((model) => toPiModel(model, serverUrl)); + models = catalog.filter((model) => modelIsSelectable(model)).map((model) => toPiModel(model, serverUrl)); }; const provider: Provider<"openai-completions"> = { @@ -133,7 +138,7 @@ export function createLlamaProvider(): LlamaProviderController { const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal }); if (context.signal.aborted) return; const refreshed = catalog - .filter((model) => model.status.value === "loaded") + .filter((model) => modelIsSelectable(model)) .map((model) => toPiModel(model, serverUrl)); await context.publish({ persist: { models: refreshed, checkedAt: Date.now() }, diff --git a/packages/coding-agent/test/llama-extension.test.ts b/packages/coding-agent/test/llama-extension.test.ts index a89a0940e..7d46c76c9 100644 --- a/packages/coding-agent/test/llama-extension.test.ts +++ b/packages/coding-agent/test/llama-extension.test.ts @@ -59,7 +59,7 @@ describe("llama.cpp extension", () => { expect(() => normalizeLlamaServerUrl("file:///tmp/llama")).toThrow("http or https"); }); - it("exposes only loaded models with router metadata", () => { + it("exposes loaded and sleeping models with router metadata", () => { const controller = createLlamaProvider(); controller.setCatalog( [ @@ -69,6 +69,7 @@ describe("llama.cpp extension", () => { architecture: { input_modalities: ["text", "image"] }, meta: { n_ctx: 65536, n_ctx_train: 131072 }, }, + { id: "sleeping", status: { value: "sleeping" } }, { id: "unloaded", status: { value: "unloaded" } }, { id: "loading", status: { value: "loading" } }, ], @@ -83,16 +84,21 @@ describe("llama.cpp extension", () => { maxTokens: 65536, input: ["text", "image"], }), + expect.objectContaining({ + id: "sleeping", + baseUrl: "http://localhost:8080/v1", + }), ]); }); - it("persists and restores loaded models for cache-only startup refreshes", async () => { + it("persists and restores selectable models for cache-only startup refreshes", async () => { let cachedEntry: ModelsStoreEntry | undefined; const { url } = await listen((request, response) => { if (request.url === "/models") { json(response, { data: [ { id: "loaded", status: { value: "loaded" }, meta: { n_ctx: 32768 } }, + { id: "sleeping", status: { value: "sleeping" }, meta: { n_ctx: 32768 } }, { id: "unloaded", status: { value: "unloaded" } }, ], }); @@ -115,8 +121,8 @@ describe("llama.cpp extension", () => { allowNetwork: true, signal: new AbortController().signal, }); - expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded"]); - expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded"]); + expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded", "sleeping"]); + expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded", "sleeping"]); const second = createLlamaProvider(); await second.provider.refreshModels?.({ @@ -128,6 +134,7 @@ describe("llama.cpp extension", () => { }); expect(second.provider.getModels()).toEqual([ expect.objectContaining({ id: "loaded", baseUrl: `${url}/v1`, contextWindow: 32768 }), + expect.objectContaining({ id: "sleeping", baseUrl: `${url}/v1`, contextWindow: 32768 }), ]); }); From 374e56e553daccf730c2c8dcb0b6e161980b43c1 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 17 Aug 2026 11:40:05 +0200 Subject: [PATCH 15/24] fix(tui): avoid duplicate VS Code right-click paste closes #8186 --- packages/coding-agent/CHANGELOG.md | 1 + packages/tui/CHANGELOG.md | 4 ++++ packages/tui/src/tui-alt-screen.ts | 8 +++++++- packages/tui/test/tui-alt-screen.test.ts | 11 ++++++++++- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5f433be9a..d8866f539 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). +- Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). ## [0.84.2] - 2026-08-14 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ae7c20028..6c0416351 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). + ## [0.84.2] - 2026-08-14 ### Added diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 783a92ab8..855fa0f71 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -695,7 +695,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private handleRightClickPaste(event: SgrMouseEvent): boolean { - if (!this.onRightClickPaste || process.platform !== "win32" || event.release || event.button !== 2) { + if ( + !this.onRightClickPaste || + process.platform !== "win32" || + process.env.TERM_PROGRAM?.toLowerCase() === "vscode" || + event.release || + event.button !== 2 + ) { return false; } try { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 2166debe7..a681cfd8c 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -225,8 +225,9 @@ describe("TuiAltScreen", () => { } }); - it("invokes the right-click paste handler only on Windows", () => { + it("invokes the right-click paste handler only on Windows outside VS Code", () => { const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const termProgram = process.env.TERM_PROGRAM; assert.ok(platformDescriptor); const terminal = new VirtualTerminal(); let pasteCount = 0; @@ -237,17 +238,25 @@ describe("TuiAltScreen", () => { }); try { Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + delete process.env.TERM_PROGRAM; tui.start(); terminal.sendInput("\x1b[<2;1;1M"); terminal.sendInput("\x1b[<2;1;1m"); assert.strictEqual(pasteCount, 1); + process.env.TERM_PROGRAM = "vscode"; + terminal.sendInput("\x1b[<2;1;1M"); + assert.strictEqual(pasteCount, 1); + Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + delete process.env.TERM_PROGRAM; terminal.sendInput("\x1b[<2;1;1M"); assert.strictEqual(pasteCount, 1); } finally { tui.stop(); Object.defineProperty(process, "platform", platformDescriptor); + if (termProgram === undefined) delete process.env.TERM_PROGRAM; + else process.env.TERM_PROGRAM = termProgram; } }); From a1bc0ec79010887210cc7de28714d72c78577dab Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:15:56 +0200 Subject: [PATCH 16/24] fix: llama.cpp guidance as no default (#8236) --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/llama-cpp.md | 2 ++ .../src/modes/interactive/interactive-mode.ts | 11 ++++++++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d8866f539..c497caf2e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,7 @@ - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). +- Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/docs/llama-cpp.md b/packages/coding-agent/docs/llama-cpp.md index 13f314c9e..bffa13f42 100644 --- a/packages/coding-agent/docs/llama-cpp.md +++ b/packages/coding-agent/docs/llama-cpp.md @@ -53,6 +53,8 @@ Start Pi and configure the provider: Enter the router URL and optional API key. The default URL is `http://127.0.0.1:8080`. +If you start the router with `--no-models-autoload`, `/login llama.cpp` only stores the connection. Run `/llama` to load a model, then `/model` to select the loaded model for the current session. + Environment variables can configure the same values without `/login`: ```bash diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 6d5e6b884..999220398 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -257,6 +257,12 @@ function hasDefaultModelProvider(providerId: string): providerId is keyof typeof return providerId in defaultModelPerProvider; } +function llamaCppPostLoginGuidance(actionLabel: string, loadedModelCount: number): string { + return loadedModelCount === 0 + ? `${actionLabel}. No llama.cpp models are loaded. Use /llama to load a model, then /model to select it.` + : `${actionLabel}. Use /model to select a loaded llama.cpp model, or /llama to manage models.`; +} + type LoginProviderCompletionOption = { id: string; name: string; @@ -5471,7 +5477,10 @@ export class InteractiveMode { if (isUnknownModel(previousModel)) { const availableModels = this.session.modelRuntime.getAvailableSnapshot(); const providerModels = availableModels.filter((model) => model.provider === providerId); - if (!hasDefaultModelProvider(providerId)) { + // Matches LLAMA_PROVIDER_ID from extensions/llama/provider.ts; kept inline to avoid coupling interactive mode to the built-in extension. + if (providerId === "llama.cpp") { + selectionError = llamaCppPostLoginGuidance(actionLabel, providerModels.length); + } else if (!hasDefaultModelProvider(providerId)) { selectionError = `${actionLabel}, but no default model is configured for provider "${providerId}". Use /model to select a model.`; } else if (providerModels.length === 0) { selectionError = `${actionLabel}, but no models are available for that provider. Use /model to select a model.`; From d3e3bbc0117751bdb7ff284feaac8082dbe9551c Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:36:17 +0200 Subject: [PATCH 17/24] fix: llama.cpp allow network for model discovery (#8238) --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/extensions/llama/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c497caf2e..0fd0c7313 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). diff --git a/packages/coding-agent/src/extensions/llama/index.ts b/packages/coding-agent/src/extensions/llama/index.ts index 0cf72141f..1e3b9ce91 100644 --- a/packages/coding-agent/src/extensions/llama/index.ts +++ b/packages/coding-agent/src/extensions/llama/index.ts @@ -53,6 +53,8 @@ export default function llamaExtension(pi: ExtensionAPI): void { provider.setCatalog(current, client.serverUrl); const result = await ctx.modelRegistry.refresh({ providers: [LLAMA_PROVIDER_ID], + // /llama already contacted the configured llama.cpp server, so keep this refresh live even in PI_OFFLINE. + allowNetwork: true, signal, }); if (result.aborted) throw new Error("Model catalog refresh timed out."); From 080932e53cb6f82076b111efc424845e4d5c1902 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:44:38 +0200 Subject: [PATCH 18/24] fix(package-manager): use semver.gt for version comparison (#8239) --- packages/coding-agent/CHANGELOG.md | 1 + .../coding-agent/src/core/package-manager.ts | 6 ++-- .../coding-agent/test/package-manager.test.ts | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0fd0c7313..e59f80e56 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). - Fixed Z.AI Coding Plan defaults referencing the removed GLM-5.1 model ([#8096](https://github.com/earendil-works/pi/issues/8096)). diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index e221d2e7e..c2e9e73d5 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -27,7 +27,7 @@ import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; -import { maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; +import { gt, maxSatisfying, rcompare, satisfies, valid, validRange } from "semver"; import { CONFIG_DIR_NAME } from "../config.ts"; import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts"; import { type GitSource, parseGitUrl } from "../utils/git.ts"; @@ -1129,7 +1129,7 @@ export class DefaultPackageManager implements PackageManager { try { const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); - return targetVersion !== installedVersion; + return gt(targetVersion, installedVersion); } catch { // Preserve existing update behavior when version lookup fails. return true; @@ -1463,7 +1463,7 @@ export class DefaultPackageManager implements PackageManager { try { const targetVersion = await this.getLatestNpmVersion(source.version ? source.spec : source.name, source.range); - return targetVersion !== installedVersion; + return gt(targetVersion, installedVersion); } catch { return false; } diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 31c89faff..86303aab9 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -2244,6 +2244,25 @@ export default function(api) { api.registerTool({ name: "test", description: "te expect(runCommandSpy).not.toHaveBeenCalled(); }); + it("should skip npm updates when the installed version is newer than the registry version", async () => { + const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "2.0.0" })); + settingsManager.setProjectPackages(["npm:example"]); + + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.9.0"'); + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + await packageManager.update("npm:example"); + + expect(runCommandCaptureSpy).toHaveBeenCalledWith( + "npm", + ["view", "example", "version", "--json"], + expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), + ); + expect(runCommandSpy).not.toHaveBeenCalled(); + }); + it("should migrate legacy user npm installs into the managed npm root during update", async () => { const legacyRoot = join(tempDir, "legacy-global", "node_modules"); const legacyPath = join(legacyRoot, "legacy-pkg"); @@ -2504,6 +2523,18 @@ export default function(api) { api.registerTool({ name: "test", description: "te ]); }); + it("should not report npm updates when the installed version is newer than the registry version", async () => { + const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "2.0.0" })); + settingsManager.setProjectPackages(["npm:example"]); + + vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.9.0"'); + + const updates = await packageManager.checkForAvailableUpdates(); + expect(updates).toEqual([]); + }); + it("should skip pinned packages when checking for updates", async () => { const installedNpmPath = join(tempDir, ".pi", "npm", "node_modules", "example"); mkdirSync(installedNpmPath, { recursive: true }); From df018b6020181d4245575fba006361ab69a1408b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 17 Aug 2026 12:55:28 +0200 Subject: [PATCH 19/24] fix(coding-agent): retry hung model catalog requests closes #8198 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/core/remote-catalog-provider.ts | 19 +++++++---- .../coding-agent/src/utils/management-http.ts | 32 ++++++++++++------- .../coding-agent/test/management-http.test.ts | 21 ++++++++++++ 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e59f80e56..9440de45e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed repeated ambiguous truncated-response recovery being mislabeled as context overflow ([#8130](https://github.com/earendil-works/pi/issues/8130)). - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). - Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). +- Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). ## [0.84.2] - 2026-08-14 diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index c79b7e3bb..a12c80ba4 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -4,6 +4,7 @@ import { fetchWithRetry } from "../utils/management-http.ts"; import { getPiUserAgent } from "../utils/pi-user-agent.ts"; const DEFAULT_CATALOG_BASE_URL = "https://pi.dev"; +const REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS = 4_000; export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000; function mergeModels(baseline: readonly Model[], dynamic: readonly Model[]): Model[] { @@ -78,14 +79,18 @@ export function withRemoteCatalog( // leave the overlay empty. const validator = stored?.models.length ? stored.etag : undefined; const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl); - const response = await fetchWithRetry(url, { - headers: { - accept: "application/json", - "User-Agent": getPiUserAgent(VERSION), - ...(validator ? { "if-none-match": validator } : {}), + const response = await fetchWithRetry( + url, + { + headers: { + accept: "application/json", + "User-Agent": getPiUserAgent(VERSION), + ...(validator ? { "if-none-match": validator } : {}), + }, + signal: context.signal, }, - signal: context.signal, - }); + { attemptTimeoutMs: REMOTE_CATALOG_ATTEMPT_TIMEOUT_MS }, + ); if (context.signal.aborted) return; const checkedAt = Date.now(); // Unchanged: dynamicModels already holds the stored overlay, so only the diff --git a/packages/coding-agent/src/utils/management-http.ts b/packages/coding-agent/src/utils/management-http.ts index bbaab9394..9a7da8144 100644 --- a/packages/coding-agent/src/utils/management-http.ts +++ b/packages/coding-agent/src/utils/management-http.ts @@ -7,8 +7,10 @@ export interface FetchRetryOptions { maxRetries?: number; /** Retry transient HTTP responses as well as transport failures. Defaults to true. */ retryOnStatus?: boolean; - /** Per-attempt timeout. A new timeout is created for every attempt. */ + /** Overall time budget shared by all attempts. */ timeoutMs?: number; + /** Per-attempt timeout. A new timeout is created for every attempt. */ + attemptTimeoutMs?: number; } /** @@ -19,8 +21,8 @@ export interface FetchRetryOptions { * agent/model operations: those can fail after the HTTP request starts and are * retried by their semantic caller instead. * - * Caller cancellation is terminal. When timeoutMs is supplied, it is the - * overall time budget shared by all attempts. + * Caller cancellation and timeoutMs are terminal. attemptTimeoutMs aborts + * only the current attempt so a hung connection can be retried. */ export async function fetchWithRetry( input: FetchInput, @@ -32,17 +34,20 @@ export async function fetchWithRetry( ? 2 : Math.max(0, Math.floor(options.maxRetries)); const retryOnStatus = options.retryOnStatus ?? true; - const parentSignal = init?.signal; + const parentSignal = init?.signal ?? undefined; const timeoutSignal = options.timeoutMs !== undefined && options.timeoutMs > 0 ? AbortSignal.timeout(options.timeoutMs) : undefined; - const signal = timeoutSignal - ? parentSignal - ? AbortSignal.any([parentSignal, timeoutSignal]) - : timeoutSignal - : parentSignal; + const attemptTimeoutMs = + options.attemptTimeoutMs !== undefined && options.attemptTimeoutMs > 0 ? options.attemptTimeoutMs : undefined; for (let attempt = 0; ; attempt++) { - signal?.throwIfAborted(); + parentSignal?.throwIfAborted(); + timeoutSignal?.throwIfAborted(); + const attemptTimeoutSignal = attemptTimeoutMs ? AbortSignal.timeout(attemptTimeoutMs) : undefined; + const signals = [parentSignal, timeoutSignal, attemptTimeoutSignal].filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0]; try { const response = await fetch(input, signal ? { ...init, signal } : init); @@ -55,10 +60,15 @@ export async function fetchWithRetry( // do if cancelling its body also fails. } } catch (error) { + const attemptTimedOut = + attemptTimeoutSignal?.aborted === true && !parentSignal?.aborted && !timeoutSignal?.aborted; if ( parentSignal?.aborted || timeoutSignal?.aborted || - (error instanceof Error && error.name === "AbortError" && timeoutSignal === undefined) || + (error instanceof Error && + error.name === "AbortError" && + !attemptTimedOut && + timeoutSignal === undefined) || attempt >= maxRetries ) { throw error; diff --git a/packages/coding-agent/test/management-http.test.ts b/packages/coding-agent/test/management-http.test.ts index b615bb2dc..cb0809cd4 100644 --- a/packages/coding-agent/test/management-http.test.ts +++ b/packages/coding-agent/test/management-http.test.ts @@ -30,6 +30,27 @@ describe("fetchWithRetry", () => { expect(signals[0]).toBe(signals[1]); }); + it("retries an attempt timeout", async () => { + const controllers: AbortController[] = []; + vi.spyOn(AbortSignal, "timeout").mockImplementation(() => { + const controller = new AbortController(); + controllers.push(controller); + return controller.signal; + }); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { + if (fetchMock.mock.calls.length === 1) { + controllers[0].abort(); + init?.signal?.throwIfAborted(); + } + return Response.json({ ok: true }); + }); + + await fetchWithRetry("https://example.test", undefined, { attemptTimeoutMs: 4000 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(controllers).toHaveLength(2); + }); + it("retries transient HTTP responses and returns the successful response", async () => { const fetchMock = vi .spyOn(globalThis, "fetch") From a6b1dbceb1af5bf8a21da0b437ef756ce9fe85e6 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:10:08 +0200 Subject: [PATCH 20/24] fix(extensions): emit compaction failed for extensions (#8241) * fix(extensions): emit compaction failed * changelog yeet --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/compaction.md | 15 +++++ packages/coding-agent/docs/extensions.md | 13 ++++- .../coding-agent/src/core/agent-session.ts | 56 ++++++++++++++++--- .../coding-agent/src/core/extensions/index.ts | 1 + .../coding-agent/src/core/extensions/types.ts | 19 ++++++- .../suite/agent-session-compaction.test.ts | 44 +++++++++++++++ 7 files changed, 139 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9440de45e..7f5ce78b4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Fixed npm package update checks treating older registry versions as available updates, preventing `pi update` from downgrading already-newer installed packages ([#8226](https://github.com/earendil-works/pi/issues/8226)). - Fixed built-in llama.cpp models disappearing from `/model` when `/llama` refreshed a configured server under `PI_OFFLINE`, and included idle-slept `sleeping` router models in the selectable catalog ([#8167](https://github.com/earendil-works/pi/issues/8167)). - Fixed `pi.registerFlag()` accepting default values that do not match the declared flag type ([#8064](https://github.com/earendil-works/pi/issues/8064)). diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 9595ca543..3a9dff1c6 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -346,6 +346,21 @@ pi.on("session_before_compact", async (event, ctx) => { See [custom-compaction.ts](../examples/extensions/custom-compaction.ts) for a complete example using a different model. +### session_compact_failed + +Fired when manual or automatic compaction fails or is aborted. This is useful for telemetry extensions that need to pair `session_before_compact` attempts with terminal outcomes. + +```typescript +pi.on("session_compact_failed", async (event, ctx) => { + const { reason, errorMessage, aborted, willRetry, fromExtension } = event; + // reason - "manual" (/compact), "threshold", or "overflow" + // errorMessage - present for non-abort failures + // aborted - true for cancelled/aborted compactions + // willRetry - whether the aborted turn would have retried after compaction + // fromExtension - whether extension-provided compaction content was being used +}); +``` + ### session_before_tree Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary. diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 9ec65f89b..2e069acdf 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -330,7 +330,8 @@ user sends another prompt ◄───────────────── /compact or auto-compaction ├─► session_before_compact (can cancel or customize) - └─► session_compact + ├─► session_compact (success) + └─► session_compact_failed (failure or abort) /tree navigation ├─► session_before_tree (can cancel or customize) @@ -448,7 +449,7 @@ pi.on("session_before_fork", async (event, ctx) => { After a successful fork or clone, pi emits `session_shutdown` for the old extension instance, reloads and rebinds extensions for the new session, then emits `session_start` with `reason: "fork"` and `previousSessionFile`. Do cleanup work in `session_shutdown`, then reestablish any in-memory state in `session_start`. -#### session_before_compact / session_compact +#### session_before_compact / session_compact / session_compact_failed Fired on compaction. See [compaction.md](compaction.md) for details. @@ -479,6 +480,14 @@ pi.on("session_compact", async (event, ctx) => { // event.reason - "manual" (/compact), "threshold", or "overflow" // event.willRetry - whether the aborted turn is retried after compaction (overflow recovery) }); + +pi.on("session_compact_failed", async (event, ctx) => { + // event.reason - "manual" (/compact), "threshold", or "overflow" + // event.errorMessage - present for non-abort failures + // event.aborted - true for cancelled/aborted compactions + // event.willRetry - whether the aborted turn would have retried after compaction + // event.fromExtension - whether extension-provided compaction content was being used +}); ``` #### session_before_tree / session_tree diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 8e7a59e89..5914a3712 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -81,6 +81,7 @@ import { type ReplacedSessionContext, type SessionBeforeCompactResult, type SessionBeforeTreeResult, + type SessionCompactFailedEvent, type SessionStartEvent, type ShutdownHandler, type ToolDefinition, @@ -574,6 +575,12 @@ export class AgentSession { }); } + private async _emitSessionCompactFailed(event: Omit): Promise { + if (this._extensionRunner.hasHandlers("session_compact_failed")) { + await this._extensionRunner.emit({ type: "session_compact_failed", ...event }); + } + } + private _getIdleWaitPromise(): Promise { if (!this._idleWaitPromise) { this._idleWaitPromise = new Promise((resolve) => { @@ -1801,6 +1808,7 @@ export class AgentSession { await this.abort(); this._compactionAbortController = new AbortController(); this._emit({ type: "compaction_start", reason: "manual" }); + let fromExtension = false; try { if (!this.model) { @@ -1823,7 +1831,6 @@ export class AgentSession { } let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; if (this._extensionRunner.hasHandlers("session_before_compact")) { const result = (await this._extensionRunner.emit({ @@ -1928,6 +1935,7 @@ export class AgentSession { } catch (error) { const message = error instanceof Error ? error.message : String(error); const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError"); + const errorMessage = aborted ? undefined : `Compaction failed: ${message}`; this._compactionAbortController = undefined; this._emit({ type: "compaction_end", @@ -1935,7 +1943,14 @@ export class AgentSession { result: undefined, aborted, willRetry: false, - errorMessage: aborted ? undefined : `Compaction failed: ${message}`, + errorMessage, + }); + await this._emitSessionCompactFailed({ + reason: "manual", + errorMessage, + aborted, + willRetry: false, + fromExtension, }); throw error; } finally { @@ -2031,6 +2046,13 @@ export class AgentSession { willRetry: false, errorMessage, }); + await this._emitSessionCompactFailed({ + reason: "overflow", + errorMessage, + aborted: false, + willRetry: false, + fromExtension: false, + }); return false; } @@ -2088,6 +2110,7 @@ export class AgentSession { private async _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise { const settings = this.settingsManager.getCompactionSettings(); let started = false; + let fromExtension = false; try { if (!this.model) { @@ -2108,7 +2131,6 @@ export class AgentSession { started = true; let extensionCompaction: CompactionResult | undefined; - let fromExtension = false; if (this._extensionRunner.hasHandlers("session_before_compact")) { const extensionResult = (await this._extensionRunner.emit({ @@ -2129,6 +2151,12 @@ export class AgentSession { aborted: true, willRetry: false, }); + await this._emitSessionCompactFailed({ + reason, + aborted: true, + willRetry: false, + fromExtension: false, + }); return false; } @@ -2182,6 +2210,12 @@ export class AgentSession { aborted: true, willRetry: false, }); + await this._emitSessionCompactFailed({ + reason, + aborted: true, + willRetry: false, + fromExtension, + }); return false; } @@ -2235,16 +2269,24 @@ export class AgentSession { } catch (error) { const errorMessage = error instanceof Error ? error.message : "compaction failed"; if (started) { + const formattedErrorMessage = + reason === "overflow" + ? `Context overflow recovery failed: ${errorMessage}` + : `Auto-compaction failed: ${errorMessage}`; this._emit({ type: "compaction_end", reason, result: undefined, aborted: false, willRetry: false, - errorMessage: - reason === "overflow" - ? `Context overflow recovery failed: ${errorMessage}` - : `Auto-compaction failed: ${errorMessage}`, + errorMessage: formattedErrorMessage, + }); + await this._emitSessionCompactFailed({ + reason, + errorMessage: formattedErrorMessage, + aborted: false, + willRetry: false, + fromExtension, }); } return false; diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index f8841aa8e..4ef89cff9 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -134,6 +134,7 @@ export type { SessionBeforeTreeEvent, SessionBeforeTreeResult, SessionCompactEvent, + SessionCompactFailedEvent, SessionEvent, SessionInfoChangedEvent, SessionShutdownEvent, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 0b8428d0e..9008d6212 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -601,7 +601,7 @@ export interface SessionBeforeCompactEvent { signal: AbortSignal; } -/** Fired after context compaction */ +/** Fired after context compaction succeeds */ export interface SessionCompactEvent { type: "session_compact"; compactionEntry: CompactionEntry; @@ -612,6 +612,21 @@ export interface SessionCompactEvent { willRetry: boolean; } +/** Fired after context compaction fails or is aborted */ +export interface SessionCompactFailedEvent { + type: "session_compact_failed"; + /** What triggered the compaction: manual /compact, the context threshold, or context overflow recovery */ + reason: "manual" | "threshold" | "overflow"; + /** Error text when compaction failed for a non-abort reason. */ + errorMessage?: string; + /** True when compaction was cancelled or aborted. */ + aborted: boolean; + /** True when the aborted turn would have been retried after this compaction (overflow recovery) */ + willRetry: boolean; + /** True when the failing compaction content came from a session_before_compact handler. */ + fromExtension: boolean; +} + /** Fired before an extension runtime is torn down due to quit, reload, or session replacement. */ export interface SessionShutdownEvent { type: "session_shutdown"; @@ -658,6 +673,7 @@ export type SessionEvent = | SessionBeforeForkEvent | SessionBeforeCompactEvent | SessionCompactEvent + | SessionCompactFailedEvent | SessionShutdownEvent | SessionBeforeTreeEvent | SessionTreeEvent; @@ -1214,6 +1230,7 @@ export interface ExtensionAPI { handler: ExtensionHandler, ): void; on(event: "session_compact", handler: ExtensionHandler): void; + on(event: "session_compact_failed", handler: ExtensionHandler): void; on(event: "session_shutdown", handler: ExtensionHandler): void; on(event: "session_before_tree", handler: ExtensionHandler): void; on(event: "session_tree", handler: ExtensionHandler): void; diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index 6a21f7f9f..5e9e7bcc8 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -278,6 +278,50 @@ describe("AgentSession compaction characterization", () => { expect(getStreamCallCount()).toBe(1); }); + it("notifies extensions when auto-compaction fails", async () => { + const failedEvents: Array<{ + reason: "manual" | "threshold" | "overflow"; + errorMessage?: string; + aborted: boolean; + willRetry: boolean; + fromExtension: boolean; + }> = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("session_compact_failed", async (event) => { + failedEvents.push(event); + }); + }, + ], + }); + harnesses.push(harness); + seedCompactableSession(harness); + harness.session.agent.streamFunction = () => { + throw new Error("summary generator blew up"); + }; + const sessionInternals = harness.session as unknown as SessionWithCompactionInternals; + + await expect(sessionInternals._runAutoCompaction("threshold", false)).resolves.toBe(false); + + expect(harness.eventsOfType("compaction_end").at(-1)).toMatchObject({ + reason: "threshold", + aborted: false, + willRetry: false, + errorMessage: "Auto-compaction failed: summary generator blew up", + }); + expect(failedEvents).toEqual([ + expect.objectContaining({ + type: "session_compact_failed", + reason: "threshold", + aborted: false, + willRetry: false, + fromExtension: false, + errorMessage: "Auto-compaction failed: summary generator blew up", + }), + ]); + }); + it("compacts and resumes after a length stop below the desired output limit", async () => { const harness = await createHarness({ models: [{ id: "faux-1", contextWindow: 1000, maxTokens: 100 }], From 1d08508ef6e4951b74190db17814ee73a6a33edc Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:10:23 +0200 Subject: [PATCH 21/24] fix(extension-examples): use agent_settled instead of end (#8242) --- .../coding-agent/examples/extensions/border-status-editor.ts | 2 +- packages/coding-agent/examples/extensions/git-checkpoint.ts | 4 ++-- packages/coding-agent/examples/extensions/notify.ts | 4 +++- packages/coding-agent/examples/extensions/titlebar-spinner.ts | 2 +- packages/coding-agent/examples/rpc-extension-ui.ts | 2 +- packages/coding-agent/examples/sdk/06-extensions.ts | 2 +- packages/coding-agent/examples/sdk/README.md | 2 +- 7 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/examples/extensions/border-status-editor.ts b/packages/coding-agent/examples/extensions/border-status-editor.ts index dce9e5959..8be7f9457 100644 --- a/packages/coding-agent/examples/extensions/border-status-editor.ts +++ b/packages/coding-agent/examples/extensions/border-status-editor.ts @@ -92,7 +92,7 @@ export default function (pi: ExtensionAPI) { activeTui?.requestRender(); }); - pi.on("agent_end", () => { + pi.on("agent_settled", () => { isWorking = false; stopSpinner(); activeTui?.requestRender(); diff --git a/packages/coding-agent/examples/extensions/git-checkpoint.ts b/packages/coding-agent/examples/extensions/git-checkpoint.ts index 7ee5e6af8..6f26cff0a 100644 --- a/packages/coding-agent/examples/extensions/git-checkpoint.ts +++ b/packages/coding-agent/examples/extensions/git-checkpoint.ts @@ -46,8 +46,8 @@ export default function (pi: ExtensionAPI) { } }); - pi.on("agent_end", async () => { - // Clear checkpoints after agent completes + pi.on("agent_settled", async () => { + // Clear checkpoints after the full agent run completes checkpoints.clear(); }); } diff --git a/packages/coding-agent/examples/extensions/notify.ts b/packages/coding-agent/examples/extensions/notify.ts index f7b1f819a..9e91400f2 100644 --- a/packages/coding-agent/examples/extensions/notify.ts +++ b/packages/coding-agent/examples/extensions/notify.ts @@ -49,7 +49,9 @@ function notify(title: string, body: string): void { } export default function (pi: ExtensionAPI) { - pi.on("agent_end", async () => { + // `agent_end` fires after each low-level run; Pi may still retry, compact, + // or continue with queued follow-ups. Notify only after the full run settles. + pi.on("agent_settled", async () => { notify("Pi", "Ready for input"); }); } diff --git a/packages/coding-agent/examples/extensions/titlebar-spinner.ts b/packages/coding-agent/examples/extensions/titlebar-spinner.ts index 51467acb6..530471d58 100644 --- a/packages/coding-agent/examples/extensions/titlebar-spinner.ts +++ b/packages/coding-agent/examples/extensions/titlebar-spinner.ts @@ -48,7 +48,7 @@ export default function (pi: ExtensionAPI) { startAnimation(ctx); }); - pi.on("agent_end", async (_event, ctx) => { + pi.on("agent_settled", async (_event, ctx) => { stopAnimation(ctx); }); diff --git a/packages/coding-agent/examples/rpc-extension-ui.ts b/packages/coding-agent/examples/rpc-extension-ui.ts index ba055d6ae..996199d11 100644 --- a/packages/coding-agent/examples/rpc-extension-ui.ts +++ b/packages/coding-agent/examples/rpc-extension-ui.ts @@ -576,7 +576,7 @@ async function main() { return; } - if (data.type === "agent_end") { + if (data.type === "agent_settled") { isStreaming = false; hideLoading(); outputLog.append(""); diff --git a/packages/coding-agent/examples/sdk/06-extensions.ts b/packages/coding-agent/examples/sdk/06-extensions.ts index 6a8e6a11a..1efdb140b 100644 --- a/packages/coding-agent/examples/sdk/06-extensions.ts +++ b/packages/coding-agent/examples/sdk/06-extensions.ts @@ -71,7 +71,7 @@ export default function (pi: ExtensionAPI) { }); pi.on("agent_end", async (event) => { - console.log(\`[Extension] Done, \${event.messages.length} messages\`); + console.log(\`[Extension] Low-level run ended, \${event.messages.length} messages\`); }); // Register a custom tool diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md index f4e4dbcda..467cc1df7 100644 --- a/packages/coding-agent/examples/sdk/README.md +++ b/packages/coding-agent/examples/sdk/README.md @@ -132,7 +132,7 @@ session.subscribe((event) => { case "tool_execution_end": console.log(`Result: ${event.result}`); break; - case "agent_end": + case "agent_settled": console.log("Done"); break; } From af2c352238cffd12d404d5a4cd35a21f93a78fe0 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 13:10:28 +0200 Subject: [PATCH 22/24] fix(ai): honor Google thinking level maps closes #8135 --- packages/ai/CHANGELOG.md | 5 + packages/ai/src/api/google-generative-ai.ts | 39 ++-- packages/ai/src/api/google-shared.ts | 37 +++- packages/ai/src/api/google-vertex.ts | 34 ++-- packages/ai/src/index.ts | 2 +- .../ai/test/google-thinking-level-map.test.ts | 170 ++++++++++++++++++ 6 files changed, 247 insertions(+), 40 deletions(-) create mode 100644 packages/ai/test/google-thinking-level-map.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index bfe0c60cf..8a69b0a29 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,9 +2,14 @@ ## [Unreleased] +### Breaking Changes + +- Renamed `GoogleThinkingLevel` to `GoogleApiThinkingLevel` and added `ResolvedGoogleThinkingLevel` for normalized adapter levels. + ### Fixed - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). +- Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). ## [0.84.2] - 2026-08-14 diff --git a/packages/ai/src/api/google-generative-ai.ts b/packages/ai/src/api/google-generative-ai.ts index b0c5a09ed..b5e5be57d 100644 --- a/packages/ai/src/api/google-generative-ai.ts +++ b/packages/ai/src/api/google-generative-ai.ts @@ -17,20 +17,20 @@ import type { TextContent, ThinkingBudgets, ThinkingContent, - ThinkingLevel, ToolCall, } from "../types.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import type { GoogleThinkingLevel } from "./google-shared.ts"; +import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; import { convertMessages, convertTools, isThinkingPart, mapStopReason, resolveGoogleFunctionCallingMode, + resolveGoogleThinkingLevel, retainThoughtSignature, retryGoogleRequest, supportsGoogleStrictToolSampling, @@ -42,7 +42,7 @@ export interface GoogleOptions extends StreamOptions { thinking?: { enabled: boolean; budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; + level?: GoogleApiThinkingLevel; }; } @@ -309,7 +309,7 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp } const clampedReasoning = clampThinkingLevel(model, options.reasoning); - const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning); const googleModel = model as Model<"google-generative-ai">; if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel) || isGemma4Model(googleModel)) { @@ -317,7 +317,7 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp ...base, thinking: { enabled: true, - level: getThinkingLevel(effort, googleModel), + level: getThinkingLevel(resolvedLevel, googleModel), }, } satisfies GoogleOptions); } @@ -326,7 +326,7 @@ export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOp ...base, thinking: { enabled: true, - budgetTokens: getGoogleBudget(googleModel, effort, options.thinkingBudgets), + budgetTokens: getGoogleBudget(googleModel, resolvedLevel, options.thinkingBudgets), }, } satisfies GoogleOptions); }; @@ -386,7 +386,7 @@ function buildParams( if (options.thinking?.enabled && model.reasoning) { const thinkingConfig: ThinkingConfig = { includeThoughts: true }; if (options.thinking.level !== undefined) { - // Cast to any since our GoogleThinkingLevel mirrors Google's ThinkingLevel enum values + // Cast to any since our GoogleApiThinkingLevel mirrors Google's ThinkingLevel enum values thinkingConfig.thinkingLevel = options.thinking.level as any; } else if (options.thinking.budgetTokens !== undefined) { thinkingConfig.thinkingBudget = options.thinking.budgetTokens; @@ -412,8 +412,6 @@ function buildParams( return params; } -type ClampedThinkingLevel = Exclude; - function isGemma4Model(model: Model<"google-generative-ai">): boolean { return /gemma-?4/.test(model.id.toLowerCase()); } @@ -445,7 +443,10 @@ function getDisabledThinkingConfig(model: Model<"google-generative-ai">): Thinki return { thinkingBudget: 0 }; } -function getThinkingLevel(effort: ClampedThinkingLevel, model: Model<"google-generative-ai">): GoogleThinkingLevel { +function getThinkingLevel( + effort: ResolvedGoogleThinkingLevel, + model: Model<"google-generative-ai">, +): GoogleApiThinkingLevel { if (isGemini3ProModel(model)) { switch (effort) { case "minimal": @@ -480,41 +481,41 @@ function getThinkingLevel(effort: ClampedThinkingLevel, model: Model<"google-gen function getGoogleBudget( model: Model<"google-generative-ai">, - effort: ClampedThinkingLevel, + level: ResolvedGoogleThinkingLevel, customBudgets?: ThinkingBudgets, ): number { - if (customBudgets?.[effort] !== undefined) { - return customBudgets[effort]!; + if (customBudgets?.[level] !== undefined) { + return customBudgets[level]!; } if (model.id.includes("2.5-pro")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 32768, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash-lite")) { - const budgets: Record = { + const budgets: Record = { minimal: 512, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } return -1; diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index fa8162400..a49c66893 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -3,7 +3,17 @@ */ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; -import type { Context, ImageContent, Model, StopReason, StreamOptions, TextContent, Tool } from "../types.ts"; +import type { + Context, + ImageContent, + Model, + ModelThinkingLevel, + StopReason, + StreamOptions, + TextContent, + ThinkingLevel, + Tool, +} from "../types.ts"; import { retryProviderRequest } from "../utils/provider-retry.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts"; @@ -15,7 +25,30 @@ type GoogleApiType = "google-generative-ai" | "google-vertex"; * Thinking level for Gemini 3 models. * Mirrors Google's ThinkingLevel enum values. */ -export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +export type GoogleApiThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; +export type ResolvedGoogleThinkingLevel = Exclude; + +/** Resolve a supported pi level or model-specific Google mapping to a standard Google level. */ +export function resolveGoogleThinkingLevel( + model: Model, + level: ModelThinkingLevel, +): ResolvedGoogleThinkingLevel { + if (level === "off") return "high"; + + const mapped = model.thinkingLevelMap?.[level]; + const resolvedLevel = typeof mapped === "string" ? mapped.toLowerCase() : level; + switch (resolvedLevel) { + case "minimal": + case "low": + case "medium": + case "high": + return resolvedLevel; + default: + throw new Error( + `Unsupported Google thinking level mapping for ${model.provider}/${model.id}: ${level} -> ${String(mapped)}`, + ); + } +} /** * Determines whether a streamed Gemini `Part` should be treated as "thinking". diff --git a/packages/ai/src/api/google-vertex.ts b/packages/ai/src/api/google-vertex.ts index 17ea6c03c..06f272fec 100644 --- a/packages/ai/src/api/google-vertex.ts +++ b/packages/ai/src/api/google-vertex.ts @@ -13,7 +13,6 @@ import type { AssistantMessage, Context, Model, - ThinkingLevel as PiThinkingLevel, ProviderEnv, ProviderHeaders, SimpleStreamOptions, @@ -29,13 +28,14 @@ import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { providerHeadersToRecord } from "../utils/headers.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts"; -import type { GoogleThinkingLevel } from "./google-shared.ts"; +import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts"; import { convertMessages, convertTools, isThinkingPart, mapStopReason, resolveGoogleFunctionCallingMode, + resolveGoogleThinkingLevel, retainThoughtSignature, retryGoogleRequest, supportsGoogleStrictToolSampling, @@ -47,7 +47,7 @@ export interface GoogleVertexOptions extends StreamOptions { thinking?: { enabled: boolean; budgetTokens?: number; // -1 for dynamic, 0 to disable - level?: GoogleThinkingLevel; + level?: GoogleApiThinkingLevel; }; project?: string; location?: string; @@ -56,7 +56,7 @@ export interface GoogleVertexOptions extends StreamOptions { const API_VERSION = "v1"; const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; -const THINKING_LEVEL_MAP: Record = { +const THINKING_LEVEL_MAP: Record = { THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED, MINIMAL: ThinkingLevel.MINIMAL, LOW: ThinkingLevel.LOW, @@ -324,7 +324,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> } const clampedReasoning = clampThinkingLevel(model, options.reasoning); - const effort = (clampedReasoning === "off" ? "high" : clampedReasoning) as ClampedThinkingLevel; + const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning); const geminiModel = model as unknown as Model<"google-generative-ai">; if (isGemini3ProModel(geminiModel) || isGemini3FlashModel(geminiModel)) { @@ -332,7 +332,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> ...base, thinking: { enabled: true, - level: getGemini3ThinkingLevel(effort, geminiModel), + level: getGemini3ThinkingLevel(resolvedLevel, geminiModel), }, } satisfies GoogleVertexOptions); } @@ -341,7 +341,7 @@ export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> ...base, thinking: { enabled: true, - budgetTokens: getGoogleBudget(geminiModel, effort, options.thinkingBudgets), + budgetTokens: getGoogleBudget(geminiModel, resolvedLevel, options.thinkingBudgets), }, } satisfies GoogleVertexOptions); }; @@ -510,8 +510,6 @@ function buildParams( return params; } -type ClampedThinkingLevel = Exclude; - function isGemini3ProModel(model: Model<"google-generative-ai">): boolean { return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase()); } @@ -538,9 +536,9 @@ function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfi } function getGemini3ThinkingLevel( - effort: ClampedThinkingLevel, + effort: ResolvedGoogleThinkingLevel, model: Model<"google-generative-ai">, -): GoogleThinkingLevel { +): GoogleApiThinkingLevel { if (isGemini3ProModel(model)) { switch (effort) { case "minimal": @@ -565,31 +563,31 @@ function getGemini3ThinkingLevel( function getGoogleBudget( model: Model<"google-generative-ai">, - effort: ClampedThinkingLevel, + level: ResolvedGoogleThinkingLevel, customBudgets?: ThinkingBudgets, ): number { - if (customBudgets?.[effort] !== undefined) { - return customBudgets[effort]!; + if (customBudgets?.[level] !== undefined) { + return customBudgets[level]!; } if (model.id.includes("2.5-pro")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 32768, }; - return budgets[effort]; + return budgets[level]; } if (model.id.includes("2.5-flash")) { - const budgets: Record = { + const budgets: Record = { minimal: 128, low: 2048, medium: 8192, high: 24576, }; - return budgets[effort]; + return budgets[level]; } return -1; diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 4ff678102..82bac9738 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -10,7 +10,7 @@ export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from export type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts"; export type { BedrockOptions, BedrockThinkingDisplay } from "./api/bedrock-converse-stream.ts"; export type { GoogleOptions } from "./api/google-generative-ai.ts"; -export type { GoogleThinkingLevel } from "./api/google-shared.ts"; +export type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./api/google-shared.ts"; export type { GoogleVertexOptions } from "./api/google-vertex.ts"; export * from "./api/lazy.ts"; export type { MistralOptions } from "./api/mistral-conversations.ts"; diff --git a/packages/ai/test/google-thinking-level-map.test.ts b/packages/ai/test/google-thinking-level-map.test.ts new file mode 100644 index 000000000..957c9b14b --- /dev/null +++ b/packages/ai/test/google-thinking-level-map.test.ts @@ -0,0 +1,170 @@ +import type { GenerateContentParameters } from "@google/genai"; +import { describe, expect, it } from "vitest"; +import { streamSimple as streamSimpleGoogle } from "../src/api/google-generative-ai.ts"; +import { resolveGoogleThinkingLevel } from "../src/api/google-shared.ts"; +import { streamSimple as streamSimpleVertex } from "../src/api/google-vertex.ts"; +import type { + Context, + Model, + ModelThinkingLevel, + ThinkingBudgets, + ThinkingLevel, + ThinkingLevelMap, +} from "../src/types.ts"; + +const context: Context = { + messages: [{ role: "user", content: "Hello", timestamp: 0 }], +}; + +function googleModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-generative-ai"> { + return { + id, + name: id, + api: "google-generative-ai", + provider: "test-google", + baseUrl: "https://example.invalid/v1beta", + reasoning: true, + thinkingLevelMap, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; +} + +function vertexModel(id: string, thinkingLevelMap: ThinkingLevelMap): Model<"google-vertex"> { + return { + id, + name: id, + api: "google-vertex", + provider: "test-vertex", + baseUrl: "https://example.invalid/v1", + reasoning: true, + thinkingLevelMap, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + }; +} + +async function captureGooglePayload( + model: Model<"google-generative-ai">, + reasoning: ThinkingLevel, + thinkingBudgets?: ThinkingBudgets, +): Promise { + let payload: GenerateContentParameters | undefined; + const result = await streamSimpleGoogle(model, context, { + apiKey: "test", + reasoning, + thinkingBudgets, + onPayload: (request) => { + payload = request as GenerateContentParameters; + throw new Error("payload captured"); + }, + }).result(); + + expect(result.errorMessage).toContain("payload captured"); + if (!payload) throw new Error("Google payload was not captured"); + return payload; +} + +async function captureVertexPayload( + model: Model<"google-vertex">, + reasoning: ThinkingLevel, + thinkingBudgets?: ThinkingBudgets, +): Promise { + let payload: GenerateContentParameters | undefined; + const result = await streamSimpleVertex(model, context, { + apiKey: "test", + reasoning, + thinkingBudgets, + onPayload: (request) => { + payload = request as GenerateContentParameters; + throw new Error("payload captured"); + }, + }).result(); + + expect(result.errorMessage).toContain("payload captured"); + if (!payload) throw new Error("Vertex payload was not captured"); + return payload; +} + +describe("Google thinking level maps", () => { + it("exhaustively resolves supported logical levels and mapping values", () => { + const defaultExpectations = { + off: "high", + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + } as const satisfies Partial>; + for (const [level, expected] of Object.entries(defaultExpectations)) { + expect(resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), level as ModelThinkingLevel)).toBe( + expected, + ); + } + + const mappedExpectations = { + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + MINIMAL: "minimal", + LOW: "low", + MEDIUM: "medium", + HIGH: "high", + } as const; + for (const [mapped, expected] of Object.entries(mappedExpectations)) { + const model = googleModel("gemini-3.7-flash", { high: mapped, xhigh: mapped, max: mapped }); + expect(resolveGoogleThinkingLevel(model, "high")).toBe(expected); + expect(resolveGoogleThinkingLevel(model, "xhigh")).toBe(expected); + expect(resolveGoogleThinkingLevel(model, "max")).toBe(expected); + } + + const invalidModel = googleModel("gemini-3.7-flash", { xhigh: "extreme" }); + expect(() => resolveGoogleThinkingLevel(invalidModel, "xhigh")).toThrow( + "Unsupported Google thinking level mapping for test-google/gemini-3.7-flash: xhigh -> extreme", + ); + expect(() => resolveGoogleThinkingLevel(googleModel("gemini-3.7-flash", {}), "max")).toThrow( + "Unsupported Google thinking level mapping for test-google/gemini-3.7-flash: max -> undefined", + ); + }); + + it.each(["xhigh", "max"] as const)("maps Google Generative AI %s to a supported level", async (reasoning) => { + const payload = await captureGooglePayload( + googleModel("gemini-3.7-flash", { xhigh: "high", max: "high" }), + reasoning, + ); + + expect(payload).toMatchObject({ config: { thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" } } }); + }); + + it("honors uppercase provider values for standard Google Generative AI levels", async () => { + const payload = await captureGooglePayload(googleModel("gemini-3.7-flash", { high: "LOW" }), "high"); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingLevel: "LOW" } } }); + }); + + it("uses mapped Google Generative AI levels for token budgets", async () => { + const payload = await captureGooglePayload(googleModel("gemini-2.5-flash", { xhigh: "high" }), "xhigh", { + high: 1234, + }); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: 1234 } } }); + }); + + it("maps Google Vertex extended levels", async () => { + const payload = await captureVertexPayload(vertexModel("gemini-3.7-flash", { xhigh: "high" }), "xhigh"); + + expect(payload).toMatchObject({ config: { thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" } } }); + }); + + it("uses mapped Google Vertex levels for token budgets", async () => { + const payload = await captureVertexPayload(vertexModel("gemini-2.5-flash", { max: "high" }), "max", { + high: 4321, + }); + + expect(payload).toMatchObject({ config: { thinkingConfig: { thinkingBudget: 4321 } } }); + }); +}); From 10acee6045e9025a22dff7e5220ed0d7538f12aa Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:24:34 +0200 Subject: [PATCH 23/24] fix(ai): bedrock response to include smithy headers (#8243) --- packages/ai/CHANGELOG.md | 1 + .../ai/src/api/bedrock-converse-stream.ts | 46 +++++++++++++- .../ai/test/bedrock-response-headers.test.ts | 63 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 packages/ai/test/bedrock-response-headers.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8a69b0a29..c6692a826 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixed +- Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). - Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). diff --git a/packages/ai/src/api/bedrock-converse-stream.ts b/packages/ai/src/api/bedrock-converse-stream.ts index 20f50efe8..d8ad43ebc 100644 --- a/packages/ai/src/api/bedrock-converse-stream.ts +++ b/packages/ai/src/api/bedrock-converse-stream.ts @@ -23,7 +23,7 @@ import { ToolResultStatus, } from "@aws-sdk/client-bedrock-runtime"; import { NodeHttpHandler } from "@smithy/node-http-handler"; -import type { BuildMiddleware, DocumentType, MetadataBearer } from "@smithy/types"; +import type { BuildMiddleware, DeserializeMiddleware, DocumentType, HttpResponse, MetadataBearer } from "@smithy/types"; import { HttpProxyAgent } from "http-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent"; import { calculateCost } from "../models.ts"; @@ -35,6 +35,7 @@ import type { ImageContent, Model, ProviderEnv, + ProviderResponse, SimpleStreamOptions, StopReason, StreamFunction, @@ -227,6 +228,12 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = try { const supportsStrictMode = model.compat?.supportsStrictMode ?? false; const client = new BedrockRuntimeClient(config); + let observedRawResponse = false; + if (options.onResponse) { + addResponseHeadersMiddleware(client, options.onResponse, model, () => { + observedRawResponse = true; + }); + } const customHeaders = providerHeadersToRecord(options.headers); if (customHeaders) { addCustomHeadersMiddleware(client, customHeaders); @@ -253,7 +260,7 @@ export const stream: StreamFunction<"bedrock-converse-stream", BedrockOptions> = const response = await client.send(command, { abortSignal: options.signal }); responseRequestId = normalizeDiagnosticValue(response.$metadata.requestId); - if (response.$metadata.httpStatusCode !== undefined) { + if (!observedRawResponse && response.$metadata.httpStatusCode !== undefined) { const responseHeaders: Record = {}; if (response.$metadata.requestId) { responseHeaders["x-amzn-requestid"] = response.$metadata.requestId; @@ -458,6 +465,41 @@ function addCustomHeadersMiddleware(client: BedrockRuntimeClient, headers: Recor client.middlewareStack.add(middleware, { step: "build", name: "pi-ai-custom-headers", priority: "low" }); } +function isSmithyHttpResponse(response: unknown): response is HttpResponse { + if (!response || typeof response !== "object") return false; + const candidate = response as Partial; + return typeof candidate.statusCode === "number" && !!candidate.headers && typeof candidate.headers === "object"; +} + +function toProviderResponse(response: unknown): ProviderResponse | undefined { + if (!isSmithyHttpResponse(response)) return undefined; + return { status: response.statusCode, headers: { ...response.headers } }; +} + +/** + * Bedrock's modeled `$metadata` only preserves selected HTTP metadata (for example + * requestId), so custom gateway headers are otherwise lost before callers see + * `onResponse`. Capture the raw Smithy HTTP response at the deserialize step, + * after the SDK receives the response but before the event stream is consumed. + */ +function addResponseHeadersMiddleware( + client: BedrockRuntimeClient, + onResponse: NonNullable, + model: Model<"bedrock-converse-stream">, + onObserved: () => void, +): void { + const middleware: DeserializeMiddleware = (next) => async (args) => { + const result = await next(args); + const providerResponse = toProviderResponse(result.response); + if (providerResponse) { + onObserved(); + await onResponse(providerResponse, model); + } + return result; + }; + client.middlewareStack.add(middleware, { step: "deserialize", name: "pi-ai-response-headers" }); +} + export const streamSimple: StreamFunction<"bedrock-converse-stream", SimpleStreamOptions> = ( model: Model<"bedrock-converse-stream">, context: Context, diff --git a/packages/ai/test/bedrock-response-headers.test.ts b/packages/ai/test/bedrock-response-headers.test.ts new file mode 100644 index 000000000..82d264882 --- /dev/null +++ b/packages/ai/test/bedrock-response-headers.test.ts @@ -0,0 +1,63 @@ +import { createServer, type Server } from "node:http"; +import { afterEach, describe, expect, it } from "vitest"; +import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts"; +import { getModel } from "../src/compat.ts"; +import type { Model, ProviderResponse } from "../src/types.ts"; + +const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"; + +let server: Server | undefined; + +afterEach(async () => { + if (!server) return; + await new Promise((resolve, reject) => { + server?.close((error) => (error ? reject(error) : resolve())); + }); + server = undefined; +}); + +async function startBedrockResponseServer(): Promise { + server = createServer((_req, res) => { + res.writeHead(200, { + "content-type": "application/vnd.amazon.eventstream", + "x-bifrost-provider": "bedrock", + "x-bifrost-resolved-model": MODEL_ID, + "x-amzn-requestid": "req-123", + }); + res.end(); + }); + + await new Promise((resolve) => server?.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP test server address"); + return `http://127.0.0.1:${address.port}`; +} + +describe("bedrock response headers", () => { + it("forwards raw Smithy response headers to onResponse", async () => { + const baseModel = getModel("amazon-bedrock", MODEL_ID) as Model<"bedrock-converse-stream">; + const model = { ...baseModel, baseUrl: await startBedrockResponseServer() }; + const responses: ProviderResponse[] = []; + + const result = await streamBedrock( + model, + { messages: [{ role: "user", content: "hello", timestamp: Date.now() }] }, + { + cacheRetention: "none", + env: { AWS_BEDROCK_FORCE_HTTP1: "1", AWS_BEDROCK_SKIP_AUTH: "1" }, + onResponse: (response) => { + responses.push(response); + }, + }, + ).result(); + + // The fake server intentionally returns an empty event stream; this assertion + // documents that the header callback still fires before stream consumption. + expect(result.stopReason).toBe("error"); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].headers["x-amzn-requestid"]).toBe("req-123"); + expect(responses[0].headers["x-bifrost-provider"]).toBe("bedrock"); + expect(responses[0].headers["x-bifrost-resolved-model"]).toBe(MODEL_ID); + }); +}); From 0e4d49541477c4fc6e404f845ad40ed47d157f24 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Mon, 17 Aug 2026 14:43:48 +0200 Subject: [PATCH 24/24] fix(ai,coding-agent): remove deprecated Xiaomi models closes #8187 --- packages/ai/CHANGELOG.md | 1 + packages/ai/scripts/generate-models.ts | 1 + packages/ai/test/xiaomi-models.test.ts | 23 ++++++++++++----------- packages/coding-agent/CHANGELOG.md | 1 + 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c6692a826..5962c9b62 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed Amazon Bedrock `after_provider_response`/`onResponse` to forward the raw response headers instead of only the synthesized request id header ([#8234](https://github.com/earendil-works/pi/issues/8234)). - Fixed Kimi OpenAI-compatible usage reporting so top-level `cached_tokens` count as cache reads instead of normal input tokens ([#8075](https://github.com/earendil-works/pi/issues/8075)). - Fixed Google Generative AI and Vertex AI custom models ignoring `thinkingLevelMap`, which dropped extended thinking controls ([#8135](https://github.com/earendil-works/pi/issues/8135)). +- Fixed Xiaomi model catalog generation retaining shut-down MiMo V2 model names after models.dev marked them deprecated ([#8187](https://github.com/earendil-works/pi/issues/8187)). ## [0.84.2] - 2026-08-14 diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 39c1f6da1..1d8e8c523 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2159,6 +2159,7 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(providerModels)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + if (m.status === "deprecated") continue; models.push({ id: modelId, diff --git a/packages/ai/test/xiaomi-models.test.ts b/packages/ai/test/xiaomi-models.test.ts index d39cf8718..992a15c43 100644 --- a/packages/ai/test/xiaomi-models.test.ts +++ b/packages/ai/test/xiaomi-models.test.ts @@ -1,17 +1,18 @@ import { describe, expect, it } from "vitest"; -import { getModel, getModels } from "../src/compat.ts"; +import { getModels } from "../src/compat.ts"; + +const XIAOMI_PROVIDERS = ["xiaomi", "xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const; +const DEPRECATED_MODEL_IDS = ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2-pro"] as const; +const REPLACEMENT_MODEL_IDS = ["mimo-v2.5", "mimo-v2.5-pro"] as const; describe("Xiaomi MiMo models", () => { - it.each(["mimo-v2-flash", "mimo-v2-omni"] as const)("keeps %s on the API billing provider", (modelId) => { - expect(getModel("xiaomi", modelId)).toBeDefined(); + it.each(XIAOMI_PROVIDERS)("omits deprecated models from %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const modelId of DEPRECATED_MODEL_IDS) expect(modelIds).not.toContain(modelId); }); - it.each(["xiaomi-token-plan-cn", "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp"] as const)( - "omits API-billing-only models from %s", - (provider) => { - const modelIds = getModels(provider).map((model) => model.id); - expect(modelIds).not.toContain("mimo-v2-flash"); - expect(modelIds).not.toContain("mimo-v2-omni"); - }, - ); + it.each(XIAOMI_PROVIDERS)("keeps replacement models on %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const modelId of REPLACEMENT_MODEL_IDS) expect(modelIds).toContain(modelId); + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7f5ce78b4..4983f6ae8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)). - Fixed llama.cpp login guidance to direct users to `/llama` before `/model` when no local models are loaded ([#8203](https://github.com/earendil-works/pi/issues/8203)). - Fixed hung pi.dev model catalog requests consuming the entire refresh deadline without retrying ([#8198](https://github.com/earendil-works/pi/issues/8198)). +- Fixed inherited Xiaomi model catalogs listing shut-down MiMo V2 models in `/model` and `--list-models` ([#8187](https://github.com/earendil-works/pi/issues/8187)). ## [0.84.2] - 2026-08-14