feat(coding-agent): merge origin/main into model runtime facade

This commit is contained in:
Mario Zechner
2026-07-15 12:25:36 +02:00
119 changed files with 4275 additions and 631 deletions
@@ -717,6 +717,8 @@ interface ProviderModelConfig {
thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
chatTemplateKwargs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort"; omitWhenOff?: boolean }>;
cacheControlFormat?: "anthropic";
sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter";
sendSessionAffinityHeaders?: boolean;
// anthropic-messages
supportsEagerToolInputStreaming?: boolean;
+138
View File
@@ -47,6 +47,7 @@ See [examples/extensions/](../examples/extensions/) for working implementations.
- [ExtensionAPI Methods](#extensionapi-methods)
- [State Management](#state-management)
- [Custom Tools](#custom-tools)
- [Dynamic Tool Loading](#dynamic-tool-loading)
- [Custom UI](#custom-ui)
- [Error Handling](#error-handling)
- [Mode Behavior](#mode-behavior)
@@ -2229,6 +2230,143 @@ If a slot renderer is not defined or throws:
- `renderCall`: Shows the tool name
- `renderResult`: Shows raw text from `content`
### Dynamic Tool Loading
Extensions can register many tools while keeping only a small initial set active. A tool can then add more tools with `pi.setActiveTools()` during execution. Pi detects purely additive changes, records the newly available tool names on that tool result, and applies the updated active set before the next model request.
This works with every model. Models with native deferred-loading support preserve the stable prompt prefix and load the new definitions at the tool-result position. Other models use the fallback described below.
The lifecycle is:
1. Register every tool with `pi.registerTool()` so it appears in `pi.getAllTools()`.
2. Keep loader tools, such as `search_tools`, active and leave searchable tools inactive.
3. During loader execution, call `pi.setActiveTools([...currentTools, ...matchingTools])`. The change must be additive: do not remove currently active tools in the same call.
4. Pi records which tools were added on the loader's tool result.
5. Before the next model response, Pi exposes the added definitions using native deferred loading when supported, or the normal active tool list otherwise.
You do not need to return provider-specific tool references or mark the loader as a special search tool. The active-tool change is the signal. Names passed to `pi.setActiveTools()` must already be registered; unknown names are ignored.
#### Models with native deferred loading
- **Anthropic**
- **Models:** Sonnet, Opus, Fable version 4.5 or newer (without Haiku)
- **Native representation:** Deferred definitions use `defer_loading`; the load point uses `tool_reference` content.
- **OpenAI**
- **Models:** `gpt-5.4` and newer family
- **Native representation:** Pi adds completed client `tool_search_call` and `tool_search_output` items at the load point.
For a verified custom model or proxy, native handling can be enabled with `compat.supportsToolReferences: true` for `anthropic-messages`, or `compat.supportsToolSearch: true` for `openai-responses` and `openai-codex-responses`. Leave these disabled unless the endpoint and model accept the corresponding native protocol.
#### Fallback behavior
For all other models and providers, dynamic activation still works: Pi sends the complete current active tool list normally on the next request. The model can call the newly activated tools, but adding their definitions may invalidate the provider's cached prompt prefix.
Pi also uses this safe fallback when the active set is not purely additive, such as replacing one group of tools with another. Tool removals therefore work, but they do not use deferred loading.
For the best cache behavior, keep the loader tool active for the whole session and add tools instead of replacing the active set. Also note that activating a tool with `promptSnippet` or `promptGuidelines` rebuilds the system prompt; that system-prompt change can invalidate the prefix even when the provider supports deferred schemas. Lazily loaded tools should usually rely on their tool `description` and omit active-only prompt metadata.
#### Search tool example
The following extension registers two searchable tools, removes them from the initial active set, and keeps only `search_tools` as their loader. The example uses simple keyword matching, but the search implementation could use BM25, embeddings, a remote catalog, or project-specific routing.
```typescript
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const SEARCHABLE_TOOL_NAMES = new Set(["lookup_weather", "search_issues"]);
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "lookup_weather",
label: "Lookup Weather",
description: "Look up the current weather for a city",
parameters: Type.Object({ city: Type.String() }),
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `Weather for ${params.city}: sunny` }],
details: {},
};
},
});
pi.registerTool({
name: "search_issues",
label: "Search Issues",
description: "Search project issues by keyword",
parameters: Type.Object({ query: Type.String() }),
async execute(_toolCallId, params) {
return {
content: [{ type: "text", text: `No open issues matching ${params.query}` }],
details: {},
};
},
});
pi.registerTool({
name: "search_tools",
label: "Search Tools",
description: "Search for and enable tools relevant to a task",
promptSnippet: "Search for additional tools when the active tools cannot perform the task",
promptGuidelines: [
"Use search_tools when a task requires a capability that is not currently available.",
],
parameters: Type.Object({
query: Type.String({ description: "Capability or task to search for" }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
}),
async execute(_toolCallId, params) {
const terms = params.query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
const matches = pi.getAllTools()
.filter((tool) => SEARCHABLE_TOOL_NAMES.has(tool.name))
.map((tool) => ({
tool,
score: terms.reduce(
(score, term) =>
score + (`${tool.name} ${tool.description}`.toLowerCase().includes(term) ? 1 : 0),
0,
),
}))
.filter((match) => match.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, params.limit ?? 3)
.map((match) => match.tool.name);
if (matches.length === 0) {
return {
content: [{ type: "text", text: `No tools found for: ${params.query}` }],
details: { matches: [] },
};
}
const active = pi.getActiveTools();
const added = matches.filter((name) => !active.includes(name));
pi.setActiveTools([...new Set([...active, ...added])]);
return {
content: [{
type: "text",
text: added.length > 0
? `Loaded tools: ${added.join(", ")}`
: `Matching tools already active: ${matches.join(", ")}`,
}],
details: { matches, added },
};
},
});
pi.on("session_start", () => {
// Keep searchable tools registered but initially inactive. Preserve built-ins
// and tools owned by other extensions, and keep the loader itself active.
const initialTools = pi.getActiveTools().filter(
(name) => !SEARCHABLE_TOOL_NAMES.has(name),
);
pi.setActiveTools([...new Set([...initialTools, "search_tools"])]);
});
}
```
When `search_tools` adds a match, the model receives that definition on the immediately following request. On a native-capable model the definition is anchored after the search result without changing the initial tool-schema prefix. On other models it appears in the normal tool list on that same following request.
## Custom UI
Extensions can interact with users via `ctx.ui` methods and customize how messages/tools render.
@@ -119,6 +119,7 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1
| Keybinding id | Default | Description |
|--------|---------|-------------|
| `app.tools.expand` | `ctrl+o` | Collapse or expand tool output |
| `app.message.copy` | `ctrl+x` | Copy the last assistant message, or the selected message in `/tree` |
| `app.message.followUp` | `alt+enter` | Queue follow-up message |
| `app.message.dequeue` | `alt+up` | Restore queued messages to editor |
+3
View File
@@ -136,6 +136,7 @@ Set `api` at provider level (default for all models) or model level (override pe
| `baseUrl` | API endpoint URL |
| `api` | API type (see above) |
| `apiKey` | Optional API key config (see value resolution below). Omit it when auth is provided by `/login`/`auth.json` or CLI `--api-key`. |
| `oauth` | Dynamic OAuth provider type. Currently supports `"radius"`; requires the gateway `baseUrl`. |
| `headers` | Custom headers (see value resolution below) |
| `authHeader` | Set `true` to add `Authorization: Bearer <apiKey>` automatically |
| `models` | Array of model configurations |
@@ -445,6 +446,8 @@ For providers with partial OpenAI compatibility, use the `compat` field.
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |
| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values |
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. |
| `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. |
| `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. |
| `supportsStrictMode` | Include the `strict` field in tool definitions |
| `supportsLongCacheRetention` | Whether the provider accepts long cache retention when cache retention is `long`: `prompt_cache_retention: "24h"` for OpenAI prompt caching, or `cache_control.ttl: "1h"` when `cacheControlFormat` is `anthropic`. Default: `true`. |
| `openRouterRouting` | OpenRouter provider routing preferences. This object is sent as-is in the `provider` field of the [OpenRouter API request](https://openrouter.ai/docs/guides/routing/provider-selection). |
+10 -1
View File
@@ -1,6 +1,6 @@
# Providers
Pi supports subscription-based providers via OAuth and API key providers via environment variables or auth file. For each provider, pi knows all available models. The list is updated with every pi release.
Pi supports subscription-based providers via OAuth and API key providers via environment variables or auth file. Built-in catalogs ship with pi; configured providers may refresh newer catalogs and cache them in `~/.pi/agent/models-store.json` for offline use.
## Table of Contents
@@ -18,6 +18,7 @@ Use `/login` in interactive mode, then select a provider:
- ChatGPT Plus/Pro (Codex)
- Claude Pro/Max
- GitHub Copilot
- Radius
Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired.
@@ -35,6 +36,10 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h
- Press Enter for github.com, or enter your GitHub Enterprise Server domain
- If you get "model not supported", enable it in VS Code: Copilot Chat → model selector → select model → "Enable"
### Radius
Radius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`.
## API Keys
### Environment Variables or Auth File
@@ -55,6 +60,7 @@ pi
| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
| NVIDIA NIM | `NVIDIA_API_KEY` | `nvidia` |
| Google Gemini | `GEMINI_API_KEY` | `google` |
| Amazon Bedrock | `AWS_BEARER_TOKEN_BEDROCK` | `amazon-bedrock` |
| Mistral | `MISTRAL_API_KEY` | `mistral` |
| Groq | `GROQ_API_KEY` | `groq` |
| Cerebras | `CEREBRAS_API_KEY` | `cerebras` |
@@ -67,6 +73,7 @@ pi
| ZAI Coding Plan (China) | `ZAI_CODING_CN_API_KEY` | `zai-coding-cn` |
| OpenCode Zen | `OPENCODE_API_KEY` | `opencode` |
| OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |
| Radius | `RADIUS_API_KEY` | `radius` |
| Hugging Face | `HF_TOKEN` | `huggingface` |
| Fireworks | `FIREWORKS_API_KEY` | `fireworks` |
| Together AI | `TOGETHER_API_KEY` | `together` |
@@ -170,6 +177,8 @@ export AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-4=my-gpt4,gpt-4o=my-gpt4o
### Amazon Bedrock
Use `/login amazon-bedrock` to store a Bedrock API key, or configure one of the ambient AWS credential sources below:
```bash
# Option 1: AWS Profile
export AWS_PROFILE=your-profile
+1 -1
View File
@@ -113,7 +113,7 @@ pi @README.md "Summarize this"
pi @src/app.ts @src/app.test.ts "Review these together"
```
Images can be pasted with Ctrl+V (Alt+V on Windows) or dragged into supported terminals.
Images or text can be pasted with Ctrl+V (Alt+V on Windows); images can also be dragged into supported terminals.
### Run shell commands
+1
View File
@@ -22,6 +22,7 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by
| File reference | Type `@` to fuzzy-search project files |
| Path completion | Press Tab to complete paths |
| Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal |
| Copy response | Ctrl+X copies the last assistant message; in `/tree`, it copies the selected message |
| Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal |
| Shell command | `!command` runs and sends output to the model |
| Hidden shell command | `!!command` runs without sending output to the model |