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

This commit is contained in:
David Brailovsky 2026-08-17 13:20:15 +00:00
commit ee6a5d29f2
62 changed files with 1266 additions and 235 deletions

View file

@ -373,3 +373,5 @@ gwokhou pr
gaoyk19 pr
cad0p pr
Jaaneek pr

View file

@ -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<SkillFrontmatter>(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 });

View file

@ -38,6 +38,13 @@ const editSchema = Type.Object(
export type EditToolInput = Static<typeof editSchema>;
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<string, unknown>;
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;

View file

@ -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"]);
});
});

View file

@ -2,6 +2,17 @@
## [Unreleased]
### Breaking Changes
- Renamed `GoogleThinkingLevel` to `GoogleApiThinkingLevel` and added `ResolvedGoogleThinkingLevel` for normalized adapter levels.
### 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)).
- 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
### Added

View file

@ -820,7 +820,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) {

View file

@ -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<any>): 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" });
@ -875,7 +872,8 @@ function applyThinkingLevelMetadata(model: Model<any>): 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,
);
@ -1632,15 +1630,14 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
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 +1943,10 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
// 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-");
@ -2162,6 +2159,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
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,

View file

@ -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<string, string> = {};
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<HttpResponse>;
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<BedrockOptions["onResponse"]>,
model: Model<"bedrock-converse-stream">,
onObserved: () => void,
): void {
const middleware: DeserializeMiddleware<object, MetadataBearer> = (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,

View file

@ -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<ThinkingLevel, "xhigh" | "max">;
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<ClampedThinkingLevel, number> = {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
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<ClampedThinkingLevel, number> = {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 512,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[effort];
return budgets[level];
}
if (model.id.includes("2.5-flash")) {
const budgets: Record<ClampedThinkingLevel, number> = {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[effort];
return budgets[level];
}
return -1;

View file

@ -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<ThinkingLevel, "xhigh" | "max">;
/** Resolve a supported pi level or model-specific Google mapping to a standard Google level. */
export function resolveGoogleThinkingLevel<T extends GoogleApiType>(
model: Model<T>,
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".

View file

@ -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<GoogleThinkingLevel, ThinkingLevel> = {
const THINKING_LEVEL_MAP: Record<GoogleApiThinkingLevel, ThinkingLevel> = {
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<PiThinkingLevel, "xhigh" | "max">;
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<ClampedThinkingLevel, number> = {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 32768,
};
return budgets[effort];
return budgets[level];
}
if (model.id.includes("2.5-flash")) {
const budgets: Record<ClampedThinkingLevel, number> = {
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
minimal: 128,
low: 2048,
medium: 8192,
high: 24576,
};
return budgets[effort];
return budgets[level];
}
return -1;

View file

@ -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,
@ -1376,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 };
@ -1383,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

View file

@ -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,

View file

@ -23,7 +23,7 @@ export type OAuthDeviceCodePollOptions<T> = {
signal: AbortSignal;
};
function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise<void> {
export function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new Error(cancelMessage));

View file

@ -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,7 +16,8 @@ const COPILOT_HEADERS = {
"Copilot-Integration-Id": "vscode-chat",
} as const;
const COPILOT_API_VERSION = "2026-06-01";
const COPILOT_POLICY_CONCURRENCY = 4;
const MAX_RETRY_AFTER_MS = 10_000;
const DEFAULT_RETRY_AFTER_MS = 1_000;
type DeviceCodeResponse = {
device_code: string;
@ -122,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<unknown> {
@ -344,13 +363,8 @@ async function enableAllGitHubCopilotModels(
enterpriseDomain: string | undefined,
signal: AbortSignal,
): Promise<void> {
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);
}
}

View file

@ -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";

View file

@ -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(),
});
}

View file

@ -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();
}

View file

@ -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<void>((resolve, reject) => {
server?.close((error) => (error ? reject(error) : resolve()));
});
server = undefined;
});
async function startBedrockResponseServer(): Promise<string> {
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<void>((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);
});
});

View file

@ -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,67 @@ 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("retries GET /models once after a 429, honoring Retry-After", async () => {
vi.useFakeTimers();
let modelsRequestCount = 0;
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
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 () => {

View file

@ -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<GenerateContentParameters> {
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<GenerateContentParameters> {
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<Record<ModelThinkingLevel, ThinkingLevel>>;
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 } } });
});
});

View file

@ -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", () => {

View file

@ -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 () => {

View file

@ -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", () => {
@ -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();

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -2,6 +2,19 @@
## [Unreleased]
### 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)).
- 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)).
- 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
### New Features
@ -33,6 +46,8 @@
### 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.
- 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)).

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -34,9 +34,10 @@ Pi loads skills from:
- CLI: `--skill <path>` (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.

View file

@ -92,7 +92,7 @@ export default function (pi: ExtensionAPI) {
activeTui?.requestRender();
});
pi.on("agent_end", () => {
pi.on("agent_settled", () => {
isWorking = false;
stopSpinner();
activeTui?.requestRender();

View file

@ -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();
});
}

View file

@ -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");
});
}

View file

@ -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);
});

View file

@ -576,7 +576,7 @@ async function main() {
return;
}
if (data.type === "agent_end") {
if (data.type === "agent_settled") {
isStreaming = false;
hideLoading();
outputLog.append("");

View file

@ -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

View file

@ -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;
}

View file

@ -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<SessionCompactFailedEvent, "type">): Promise<void> {
if (this._extensionRunner.hasHandlers("session_compact_failed")) {
await this._extensionRunner.emit({ type: "session_compact_failed", ...event });
}
}
private _getIdleWaitPromise(): Promise<void> {
if (!this._idleWaitPromise) {
this._idleWaitPromise = new Promise((resolve) => {
@ -1784,13 +1791,24 @@ 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<CompactionResult> {
await this.abort();
this._compactionAbortController = new AbortController();
this._emit({ type: "compaction_start", reason: "manual" });
let fromExtension = false;
try {
if (!this.model) {
@ -1813,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({
@ -1850,7 +1867,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,
@ -1863,6 +1880,7 @@ export class AgentSession {
env,
this.settingsManager.getRetrySettings(),
this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }),
undefined, // sessionId
);
summary = result.summary;
firstKeptEntryId = result.firstKeptEntryId;
@ -1917,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",
@ -1924,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 {
@ -1948,16 +1974,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<boolean> {
const settings = this.settingsManager.getCompactionSettings();
@ -1985,35 +2020,45 @@ 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 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
// agent.continue() cannot continue from a completed assistant response.
if (!willRetry) {
return await this._runAutoCompaction("overflow", false);
}
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,
});
await this._emitSessionCompactFailed({
reason: "overflow",
errorMessage,
aborted: false,
willRetry: false,
fromExtension: false,
});
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 +2066,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,11 +2098,19 @@ 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<boolean> {
const settings = this.settingsManager.getCompactionSettings();
let started = false;
let fromExtension = false;
try {
if (!this.model) {
@ -2078,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({
@ -2099,6 +2151,12 @@ export class AgentSession {
aborted: true,
willRetry: false,
});
await this._emitSessionCompactFailed({
reason,
aborted: true,
willRetry: false,
fromExtension: false,
});
return false;
}
@ -2122,7 +2180,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,
@ -2135,6 +2193,7 @@ export class AgentSession {
env,
this.settingsManager.getRetrySettings(),
this._summarizationRetryCallbacks({ source: "compaction", reason }),
undefined, // sessionId
);
summary = compactResult.summary;
firstKeptEntryId = compactResult.firstKeptEntryId;
@ -2151,6 +2210,12 @@ export class AgentSession {
aborted: true,
willRetry: false,
});
await this._emitSessionCompactFailed({
reason,
aborted: true,
willRetry: false,
fromExtension,
});
return false;
}
@ -2204,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;

View file

@ -544,8 +544,9 @@ function createSummarizationOptions(
env: Record<string, string> | 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<AssistantMessage> {
// 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<AssistantMessage> =>
streamFn
@ -598,6 +600,7 @@ export async function generateSummary(
env?: Record<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
sessionId?: string,
): Promise<string> {
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<string, string>,
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<string, string>,
retry?: RetryPolicy,
callbacks?: RetryCallbacks,
sessionId?: string,
): Promise<CompactionResult> {
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,

View file

@ -134,6 +134,7 @@ export type {
SessionBeforeTreeEvent,
SessionBeforeTreeResult,
SessionCompactEvent,
SessionCompactFailedEvent,
SessionEvent,
SessionInfoChangedEvent,
SessionShutdownEvent,

View file

@ -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);

View file

@ -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<SessionBeforeCompactEvent, SessionBeforeCompactResult>,
): void;
on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): void;
on(event: "session_compact_failed", handler: ExtensionHandler<SessionCompactFailedEvent>): void;
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
on(event: "session_before_tree", handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>): void;
on(event: "session_tree", handler: ExtensionHandler<SessionTreeEvent>): void;
@ -1271,11 +1288,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. */

View file

@ -32,11 +32,11 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
"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.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",

View file

@ -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;
}

View file

@ -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<Api>[], dynamic: readonly Model<Api>[]): Model<Api>[] {
@ -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

View file

@ -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<SkillFrontmatter>(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<SkillFrontmatter>(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,
};
}
/**

View file

@ -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<string, unknown>;
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<string, unknown>;
// 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;

View file

@ -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.");

View file

@ -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() },

View file

@ -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.`;

View file

@ -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;

View file

@ -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,

View file

@ -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) {

View file

@ -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 }),
]);
});

View file

@ -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")

View file

@ -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,17 +701,32 @@ 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");
});
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");
});

View file

@ -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 });

View file

@ -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 }],
@ -353,6 +397,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.",
);
});

View file

@ -2,9 +2,9 @@
## [Unreleased]
### Added
### Fixed
- Added fullscreen component mouse dispatch with hit-testing, bubbling through `Container` and `Box`, pointer capture, focus transfer, overlays, render control, and the `MouseRegion` wrapper; `Input`, `Editor`, `SelectList`, and `SettingsList` now support pointer interaction.
- 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

View file

@ -902,7 +902,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 {

View file

@ -265,8 +265,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;
@ -277,17 +278,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;
}
});