Merge current upstream main

This commit is contained in:
yuzehui02 2026-11-04 16:36:33 +08:00
commit 83124c38c2
8 changed files with 66 additions and 6 deletions

View file

@ -12,6 +12,7 @@
- Fixed OpenCode Go Claude Haiku 5.5 failing with a 400 (`Invalid signature in thinking block ... bound to a different conversation`) after the tool list, system prompt, or earlier history changed, which left the session failing on every retry. OpenCode Go and Zen Claude 5.5 models, Opus 5, and Fable 5.1 now use per-message effort and send `thinking.block_binding: drop_block`, like the Anthropic provider ([#10954](https://github.com/earendil-works/pi/issues/10954))
- Fixed Anthropic non-strict tool schemas dropping root `$defs` and `definitions`, which left property `$ref`s pointing at nothing. Claude then produced wrong arguments for such tools (common in MCP servers), for example a JSON string instead of an object, and some Anthropic-compatible gateways rejected the request outright ([#9557](https://github.com/earendil-works/pi/issues/9557))
- Fixed OpenRouter rejecting requests with a 400 (`This endpoint's maximum context length is ...`) for models whose output limit is close to the context window, such as DeepSeek flash. OpenRouter counts the input with its own tokenizer, which can be several percent higher than the provider's count, and pi left only 4,096 tokens spare. pi then treated the 400 as a context overflow and compacted the session, even at around 20% of the window. The default output limit now leaves 15% of the estimated input unused, and near a full window at most 32,768 output tokens are requested ([#10497](https://github.com/earendil-works/pi/issues/10497))
- Fixed Azure GPT-6 Astra, Luna, Sol, GPT-6.1 Sol, and GPT-6.1 Terra using a 272K context window instead of the 1,050,000 tokens Azure Foundry deploys them with ([#11742](https://github.com/earendil-works/pi/issues/11742))
## [1.5.0] - 2026-11-02

View file

@ -10,6 +10,11 @@ const CONTEXT_WINDOW_OVERRIDES: Record<string, number> = {
"gpt-5.6-luna": 1050000,
"gpt-5.6-sol": 1050000,
"gpt-5.6-terra": 1050000,
"gpt-6-astra": 1050000,
"gpt-6-luna": 1050000,
"gpt-6-sol": 1050000,
"gpt-6.1-sol": 1050000,
"gpt-6.1-terra": 1050000,
};
// Azure resells DeepSeek at its own rates. US data zone, checked 2026-09-16.

View file

@ -169,6 +169,13 @@ describe("getSupportedThinkingLevels", () => {
}
});
it.each(["gpt-6-astra", "gpt-6-luna", "gpt-6-sol", "gpt-6.1-sol", "gpt-6.1-terra"] as const)(
"uses the Azure Foundry 1.05M context window for %s",
(modelId) => {
expect(getModel("azure", modelId)?.contextWindow).toBe(1050000);
},
);
it("includes only medium/high/xhigh for OpenAI GPT-5.5 Pro", () => {
const model = getModel("openai", "gpt-5.5-pro");
expect(model).toBeDefined();

View file

@ -126,7 +126,7 @@ In fullscreen mode, these actions control the transcript and take precedence ove
| `app.exit` | `ctrl+d` | Exit (when editor empty) |
| `app.suspend` | `ctrl+z` (None on Windows) | Suspend to background |
| `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) |
| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows and WSL) | Paste files on macOS, images, or text from clipboard |
| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v`, `ctrl+v` on Windows and WSL) | Paste files on macOS, images, or text from clipboard |
On native Windows, `app.suspend` has no default because Windows terminals do not support Unix job control. If you assign it manually, Pi shows a status message instead of suspending. WSL uses the normal `ctrl+z` and `fg` behavior.

View file

@ -73,7 +73,7 @@ const APP_KEYBINDINGS = {
description: "Restore queued messages",
},
"app.clipboard.pasteImage": {
defaultKeys: windowsKeybindings ? "alt+v" : "ctrl+v",
defaultKeys: windowsKeybindings ? ["alt+v", "ctrl+v"] : "ctrl+v",
description: "Paste files on macOS, images, or text from clipboard",
},
"app.session.new": { defaultKeys: [], description: "Start a new session" },

View file

@ -158,6 +158,13 @@ export function llamaInferenceUrl(serverUrl: string): string {
return `${normalizeLlamaServerUrl(serverUrl)}/v1`;
}
function connectionErrorDetail(error: unknown): string {
const cause = error instanceof Error ? error.cause : undefined;
if (cause instanceof Error && cause.message) return cause.message;
if (cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string") return cause.code;
return error instanceof Error ? error.message : String(error);
}
export class LlamaClient {
readonly serverUrl: string;
private readonly apiKey: string | undefined;
@ -167,13 +174,25 @@ export class LlamaClient {
this.apiKey = apiKey;
}
/** Fetches from the server, naming the server and the network-level cause when the connection fails. */
private async fetchServer(path: string, init: RequestInit): Promise<Response> {
try {
return await fetch(`${this.serverUrl}${path}`, init);
} catch (error) {
if (init.signal?.aborted) throw error;
throw new Error(`Could not reach llama.cpp server at ${this.serverUrl}: ${connectionErrorDetail(error)}`, {
cause: error,
});
}
}
private async request(path: string, init: RequestInit = {}): Promise<unknown> {
const headers = new Headers(init.headers);
if (init.body !== undefined) headers.set("Content-Type", "application/json");
if (this.apiKey) headers.set("Authorization", `Bearer ${this.apiKey}`);
const timeout = AbortSignal.timeout(15_000);
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
const response = await fetch(`${this.serverUrl}${path}`, { ...init, headers, signal });
const response = await this.fetchServer(path, { ...init, headers, signal });
let payload: unknown;
try {
payload = await response.json();
@ -229,7 +248,7 @@ export class LlamaClient {
async watch(onEvent: (event: LlamaModelEvent) => void, signal?: AbortSignal): Promise<void> {
const headers = new Headers();
if (this.apiKey) headers.set("Authorization", `Bearer ${this.apiKey}`);
const response = await fetch(`${this.serverUrl}/models/sse`, { headers, signal });
const response = await this.fetchServer("/models/sse", { headers, signal });
if (!response.ok || !response.body) throw new Error(`llama.cpp SSE returned HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { KEYBINDINGS, useWindowsKeybindings } from "../src/core/keybindings.ts";
describe("Windows keybinding defaults", () => {
@ -20,11 +20,26 @@ describe("Windows keybinding defaults", () => {
expect(useWindowsKeybindings("darwin", {})).toBe(false);
});
it("keeps ctrl+v as a paste key on WSL", async () => {
if (process.platform !== "linux") return;
vi.stubEnv("WSL_DISTRO_NAME", "Ubuntu");
vi.resetModules();
try {
const { KEYBINDINGS: wslKeybindings } = await import("../src/core/keybindings.ts");
expect(wslKeybindings["app.clipboard.pasteImage"].defaultKeys).toEqual(["alt+v", "ctrl+v"]);
} finally {
vi.unstubAllEnvs();
vi.resetModules();
}
});
it("applies the detected defaults consistently", () => {
const windowsKeybindings = useWindowsKeybindings();
const nativeWindows = process.platform === "win32";
expect(KEYBINDINGS["app.clipboard.pasteImage"].defaultKeys).toBe(windowsKeybindings ? "alt+v" : "ctrl+v");
expect(KEYBINDINGS["app.clipboard.pasteImage"].defaultKeys).toEqual(
windowsKeybindings ? ["alt+v", "ctrl+v"] : "ctrl+v",
);
expect(KEYBINDINGS["tui.altScreen.search"].defaultKeys).toBe(windowsKeybindings ? "ctrl+f" : "ctrl+shift+f");
expect(KEYBINDINGS["app.message.followUp"].defaultKeys).toBe(windowsKeybindings ? "ctrl+q" : "alt+enter");
expect(KEYBINDINGS["app.model.cycleBackward"].defaultKeys).toBe(windowsKeybindings ? "alt+p" : "shift+ctrl+p");

View file

@ -59,6 +59,19 @@ describe("llama.cpp extension", () => {
expect(() => normalizeLlamaServerUrl("file:///tmp/llama")).toThrow("http or https");
});
// #11745: a refused connection showed only "fetch failed"
it("names the server and the cause when it cannot be reached", async () => {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve) => server.close(() => resolve()));
await expect(new LlamaClient(`http://127.0.0.1:${port}`).list()).rejects.toThrow(
`Could not reach llama.cpp server at http://127.0.0.1:${port}: connect ECONNREFUSED 127.0.0.1:${port}`,
);
});
it("exposes loaded and sleeping models with router metadata", () => {
const controller = createLlamaProvider();
controller.setCatalog(