mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
commit
ee6a5d29f2
62 changed files with 1266 additions and 235 deletions
2
.github/APPROVED_CONTRIBUTORS
vendored
2
.github/APPROVED_CONTRIBUTORS
vendored
|
|
@ -373,3 +373,5 @@ gwokhou pr
|
|||
gaoyk19 pr
|
||||
|
||||
cad0p pr
|
||||
|
||||
Jaaneek pr
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
63
packages/ai/test/bedrock-response-headers.test.ts
Normal file
63
packages/ai/test/bedrock-response-headers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
170
packages/ai/test/google-thinking-level-map.test.ts
Normal file
170
packages/ai/test/google-thinking-level-map.test.ts
Normal 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 } } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export default function (pi: ExtensionAPI) {
|
|||
activeTui?.requestRender();
|
||||
});
|
||||
|
||||
pi.on("agent_end", () => {
|
||||
pi.on("agent_settled", () => {
|
||||
isWorking = false;
|
||||
stopSpinner();
|
||||
activeTui?.requestRender();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -576,7 +576,7 @@ async function main() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (data.type === "agent_end") {
|
||||
if (data.type === "agent_settled") {
|
||||
isStreaming = false;
|
||||
hideLoading();
|
||||
outputLog.append("");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ export type {
|
|||
SessionBeforeTreeEvent,
|
||||
SessionBeforeTreeResult,
|
||||
SessionCompactEvent,
|
||||
SessionCompactFailedEvent,
|
||||
SessionEvent,
|
||||
SessionInfoChangedEvent,
|
||||
SessionShutdownEvent,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue