feat(coding-agent): merge origin/main into model runtime facade
This commit is contained in:
@@ -8,69 +8,56 @@
|
||||
- Removed redundant `ModelRuntime.getAll()`, `find()`, `getSnapshot()`, and `getAuthOptions()` projections. Use the pi-ai `Models` methods `getModels()`, `getModel()`, `getProviders()`, and `checkAuth()` directly.
|
||||
- Replaced SDK request-auth assembly through `ModelRegistry.getApiKeyAndHeaders()` with `ModelRuntime.getAuth()`. Passing a provider ID returns provider-scoped auth; passing a model also resolves built-in, `models.json`, and extension model headers.
|
||||
- Changed extension-facing `ModelRegistry.refresh()` from synchronous `void` to `Promise<void>` because `models.json` loading is asynchronous. Extensions must await it before making synchronous registry reads.
|
||||
- Removed extension OAuth `modifyModels`. Provider catalogs are now composed independently of credentials; credential-specific availability belongs to canonical provider filtering. The legacy extension OAuth callback and credential types remain available from pi-ai's root and `oauth` subpath.
|
||||
- Moved canonical dynamic catalog refresh to async `ModelRuntime.refresh()`/pi-ai `Models.refresh()`. Legacy extension OAuth `modifyModels` remains supported as a synchronous compatibility projection after credential initialization.
|
||||
- Removed the `openai-responses` `compat.sendSessionIdHeader` flag from `models.json`. Session-affinity behavior is now controlled by `compat.sessionAffinityFormat` (`"openai"`, `"openai-nosession"`, or `"openrouter"`). Replace `sendSessionIdHeader: false` with `sessionAffinityFormat: "openai-nosession"` ([#6366](https://github.com/earendil-works/pi/issues/6366)).
|
||||
|
||||
#### SDK migration
|
||||
|
||||
Construct one `ModelRuntime` and pass it to `createAgentSession()`:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const authStorage = AuthStorage.create(authPath);
|
||||
const modelRegistry = await ModelRegistry.create(authStorage, modelsPath);
|
||||
authStorage.setRuntimeApiKey("anthropic", apiKey);
|
||||
const { session } = await createAgentSession({ authStorage, modelRegistry });
|
||||
|
||||
// After
|
||||
const modelRuntime = await ModelRuntime.create({ authPath, modelsPath });
|
||||
// Or: ModelRuntime.create({ credentials: myCredentialStore, modelsPath })
|
||||
modelRuntime.setRuntimeApiKey("anthropic", apiKey);
|
||||
const { session } = await createAgentSession({ modelRuntime });
|
||||
```
|
||||
|
||||
Replace `ModelRegistry` projections with the corresponding `ModelRuntime`/pi-ai `Models` methods:
|
||||
|
||||
```typescript
|
||||
const allModels = modelRuntime.getModels();
|
||||
const model = modelRuntime.getModel(providerId, modelId);
|
||||
const availableModels = await modelRuntime.getAvailable();
|
||||
const authStatus = await modelRuntime.checkAuth(providerId);
|
||||
const requestAuth = await modelRuntime.getAuth(model); // Includes model headers
|
||||
|
||||
modelRuntime.registerProvider(providerId, providerConfig); // Still synchronous
|
||||
await modelRuntime.reloadConfig();
|
||||
```
|
||||
|
||||
`ModelRuntime.stream*()` resolves auth and configured headers itself. Do not call `getAuth(model)` before streaming merely to reconstruct request options. For SDK-level header interception, use the Models-only transform so auth is resolved once:
|
||||
|
||||
```typescript
|
||||
modelRuntime.streamSimple(model, context, {
|
||||
transformHeaders: async (headers) => ({
|
||||
...headers,
|
||||
"X-Request-ID": requestId,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Use `ModelRuntime` for model lookup, availability, provider auth, login/logout, runtime API-key overrides, provider registration, and config refresh. `ModelRegistry` remains a synchronous-read compatibility facade for extensions; SDK code should use `ModelRuntime`. Extensions that explicitly refresh it must await completion:
|
||||
|
||||
```typescript
|
||||
await ctx.modelRegistry.refresh();
|
||||
const models = ctx.modelRegistry.getAll();
|
||||
```
|
||||
### New Features
|
||||
|
||||
- **Cache-friendly dynamic tool loading** - Extensions can add tools during execution while supported Anthropic and OpenAI Responses models preserve prompt-cache prefixes. See [Dynamic Tool Loading](docs/extensions.md#dynamic-tool-loading).
|
||||
- **Message copy shortcut** - `Ctrl+X` copies the last assistant message in the transcript or the selected message in `/tree`, making older and branched messages directly copyable. See [Display and Message Queue](docs/keybindings.md#display-and-message-queue).
|
||||
- **Fable 5 `xhigh` and `max` thinking** - Native `xhigh` and `max` thinking levels are available across generated provider catalogs. See [Model Options](docs/usage.md#model-options).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `ModelRuntime` as the canonical async SDK and internal model/auth facade while preserving the synchronous extension-facing `ModelRegistry` API. `ModelRuntime.create()` accepts any pi-ai `CredentialStore` through its `credentials` option.
|
||||
- Added provider-owned `/login` discovery directly from registered pi-ai providers, including ambient auth status and informational links.
|
||||
- Added the opt-in `max` thinking level across CLI, SDK, RPC, model selection, and themes. Custom themes can define `thinkingMax`; existing themes fall back to `thinkingXhigh`.
|
||||
- Added request-wide input-token pricing tiers to custom model costs in `models.json`, `modelOverrides`, and extension-registered providers.
|
||||
- Added file-backed dynamic catalogs in `models-store.json`, per-provider pi.dev catalog overlays, and Radius gateway support including offline migration from legacy credential-cached catalogs.
|
||||
- Added cache-friendly dynamic tool loading for extension tools activated by tool results. Supported Anthropic and OpenAI Responses models load definitions where they become available, preserving the cached prompt prefix. See [Dynamic Tool Loading](docs/extensions.md#dynamic-tool-loading) ([#6474](https://github.com/earendil-works/pi-mono/pull/6474)).
|
||||
- Added inherited native `xhigh` and `max` thinking levels for Claude Fable 5 across all generated provider catalogs ([#6490](https://github.com/earendil-works/pi-mono/pull/6490) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Added `Ctrl+X` to copy the last assistant message, or the selected message in `/tree`.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `ModelRuntime` to compose built-in providers, immutable `models.json` configuration, and extension overlays through ad-hoc pi-ai provider methods.
|
||||
- Changed `ModelRuntime` to own final request assembly: `getAuth(model)` includes configured model headers, stream methods resolve auth once, and `before_provider_headers` runs as the Models-only header transform before provider dispatch.
|
||||
- Changed `/model` to render the current model snapshot immediately, refresh configured providers in the background, and update the open selector with partial results or timeout errors.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed inherited OpenRouter model context windows to use the top provider's actual context length ([#6481](https://github.com/earendil-works/pi-mono/pull/6481) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Fixed inherited OpenRouter OpenAI-compatible session IDs to use the `x-session-id` header instead of OpenAI-specific session-affinity fields ([#6366](https://github.com/earendil-works/pi/issues/6366)).
|
||||
- Fixed `Ctrl+V` to paste clipboard text when the pasteboard does not contain an image.
|
||||
- Fixed `/login amazon-bedrock` to prompt for and save a Bedrock API key instead of only displaying ambient AWS credential setup instructions.
|
||||
|
||||
## [0.80.6] - 2026-07-09
|
||||
|
||||
### New Features
|
||||
|
||||
- **`max` thinking level** - New opt-in thinking level above `xhigh`, natively supported on GPT-5.6 and adaptive Claude models, available across CLI (`--thinking max`), SDK, RPC, and model selection. Custom themes can define `thinkingMax`. See [CLI Reference](docs/usage.md#cli-reference).
|
||||
- **Input-based pricing tiers** - Request-wide input-token pricing tiers for accurate long-context cost accounting (e.g. GPT-5.4/5.5/5.6 long-context rates), also configurable for custom models in `models.json` and `modelOverrides`. See [Model Configuration](docs/models.md#model-configuration).
|
||||
|
||||
### Added
|
||||
|
||||
- Added the opt-in `max` thinking level across CLI, SDK, RPC, model selection, and themes. Custom themes can define `thinkingMax`; existing themes fall back to `thinkingXhigh`.
|
||||
- Added request-wide input-token pricing tiers to custom model costs in `models.json`, `modelOverrides`, and extension-registered providers.
|
||||
- Added `~` (home directory) expansion for the `shellPath` setting ([#6470](https://github.com/earendil-works/pi/pull/6470) by [@aaronkyriesenbach](https://github.com/aaronkyriesenbach)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed inherited post-compaction output-token budgeting to ignore stale assistant usage from before the compaction boundary ([#6464](https://github.com/earendil-works/pi/issues/6464)).
|
||||
- Fixed inherited GPT-5.4 and GPT-5.5 long-context cost accounting while retaining the intentional 272K default context limit for models that require an explicit override.
|
||||
- Fixed inherited GPT-5.6 metadata to keep direct OpenAI requests in the 272K short-context tier while exposing the Codex backend's 372K context window with long-context pricing, and removed the nonexistent bare `gpt-5.6` alias.
|
||||
- Fixed inherited Anthropic message conversion to preserve thinking blocks with empty thinking text but a valid signature instead of dropping them, avoiding thinking-block errors on newer Claude models ([#6457](https://github.com/earendil-works/pi/pull/6457) by [@davidbrai](https://github.com/davidbrai)).
|
||||
|
||||
## [0.80.5] - 2026-07-09
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ The editor can be temporarily replaced by other UI, like built-in `/settings` or
|
||||
| Path completion | Tab to complete paths |
|
||||
| Multi-line | Shift+Enter (or Ctrl+Enter on Windows Terminal) |
|
||||
| External editor | Ctrl+G opens `externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere |
|
||||
| Images | Ctrl+V to paste (Alt+V on Windows), or drag onto terminal |
|
||||
| Clipboard | Ctrl+V to paste an image or text (Alt+V on Windows), or drag images onto terminal |
|
||||
| Bash commands | `!command` runs and sends output to LLM, `!!command` runs without sending |
|
||||
|
||||
Standard editing keybindings for delete word, undo, etc. See [docs/keybindings.md](docs/keybindings.md).
|
||||
@@ -212,6 +212,7 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`.
|
||||
| Shift+Tab | Cycle thinking level |
|
||||
| Ctrl+O | Collapse/expand tool output |
|
||||
| Ctrl+T | Collapse/expand thinking blocks |
|
||||
| Ctrl+X | Copy the last assistant message |
|
||||
|
||||
### Message Queue
|
||||
|
||||
@@ -255,6 +256,7 @@ Use `/session` in interactive mode to see the current session ID before reusing
|
||||
|
||||
- Search by typing, fold/unfold and jump between branches with Ctrl+←/Ctrl+→ or Alt+←/Alt+→, page with ←/→
|
||||
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
|
||||
- Press Ctrl+X to copy the selected message
|
||||
- Press Shift+L to label entries as bookmarks and Shift+T to toggle label timestamps
|
||||
|
||||
**`/fork`** - Create a new session file from a previous user message on the active branch. Opens a selector, copies the active path up to that point, and places the selected prompt in the editor for modification.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"private": true,
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||
"private": true,
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"private": true,
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.10.5",
|
||||
"version": "1.10.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.10.5",
|
||||
"version": "1.10.6",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"private": true,
|
||||
"version": "1.10.5",
|
||||
"version": "1.10.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
+15
-15
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.80.5"
|
||||
"@earendil-works/pi-coding-agent": "0.80.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -450,11 +450,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.80.5",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.5.tgz",
|
||||
"version": "0.80.6",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.80.5",
|
||||
"@earendil-works/pi-ai": "^0.80.6",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
"yaml": "2.9.0"
|
||||
@@ -464,8 +464,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.80.5",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.5.tgz",
|
||||
"version": "0.80.6",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
@@ -488,13 +488,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent": {
|
||||
"version": "0.80.5",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.5.tgz",
|
||||
"version": "0.80.6",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.80.5",
|
||||
"@earendil-works/pi-ai": "^0.80.5",
|
||||
"@earendil-works/pi-tui": "^0.80.5",
|
||||
"@earendil-works/pi-agent-core": "^0.80.6",
|
||||
"@earendil-works/pi-ai": "^0.80.6",
|
||||
"@earendil-works/pi-tui": "^0.80.6",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -522,8 +522,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.80.5",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.5.tgz",
|
||||
"version": "0.80.6",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"private": true,
|
||||
"description": "Lockfile root used by the Pi installer and updater.",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.80.5"
|
||||
"@earendil-works/pi-coding-agent": "0.80.6"
|
||||
},
|
||||
"overrides": {
|
||||
"rimraf": "6.1.2",
|
||||
|
||||
+12
-12
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.80.5",
|
||||
"@earendil-works/pi-ai": "^0.80.5",
|
||||
"@earendil-works/pi-tui": "^0.80.5",
|
||||
"@earendil-works/pi-agent-core": "^0.80.6",
|
||||
"@earendil-works/pi-ai": "^0.80.6",
|
||||
"@earendil-works/pi-tui": "^0.80.6",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -474,11 +474,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.80.5",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.5.tgz",
|
||||
"version": "0.80.6",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.80.5",
|
||||
"@earendil-works/pi-ai": "^0.80.6",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
"yaml": "2.9.0"
|
||||
@@ -488,8 +488,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.80.5",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.5.tgz",
|
||||
"version": "0.80.6",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
@@ -512,8 +512,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.80.5",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.5.tgz",
|
||||
"version": "0.80.6",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.80.5",
|
||||
"version": "0.80.6",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"type": "module",
|
||||
"piConfig": {
|
||||
@@ -39,9 +39,9 @@
|
||||
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.80.5",
|
||||
"@earendil-works/pi-ai": "^0.80.5",
|
||||
"@earendil-works/pi-tui": "^0.80.5",
|
||||
"@earendil-works/pi-agent-core": "^0.80.6",
|
||||
"@earendil-works/pi-ai": "^0.80.6",
|
||||
"@earendil-works/pi-tui": "^0.80.6",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
|
||||
@@ -165,6 +165,7 @@ export async function createAgentSessionServices(
|
||||
}
|
||||
}
|
||||
extensionsResult.runtime.pendingProviderRegistrations = [];
|
||||
await modelRuntime.refresh({ allowNetwork: false });
|
||||
diagnostics.push(...applyExtensionFlagValues(resourceLoader, options.extensionFlagValues));
|
||||
|
||||
return {
|
||||
|
||||
@@ -419,7 +419,7 @@ export class AgentSession {
|
||||
throw new Error(formatNoApiKeyFoundMessage(model.provider));
|
||||
}
|
||||
|
||||
private async _getCompactionRequestAuth(model: Model<any>): Promise<{
|
||||
private async _getSummarizationRequestAuth(model: Model<any>): Promise<{
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
@@ -1779,7 +1779,7 @@ export class AgentSession {
|
||||
throw new Error(formatNoModelSelectedMessage());
|
||||
}
|
||||
|
||||
const { apiKey, headers, env } = await this._getCompactionRequestAuth(this.model);
|
||||
const { apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model);
|
||||
|
||||
const pathEntries = this.sessionManager.getBranch();
|
||||
const settings = this.settingsManager.getCompactionSettings();
|
||||
@@ -2045,7 +2045,7 @@ export class AgentSession {
|
||||
headers = withoutDeletedHeaders(authResult.auth.headers);
|
||||
env = authResult.env;
|
||||
} else {
|
||||
({ apiKey, headers, env } = await this._getCompactionRequestAuth(this.model));
|
||||
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
|
||||
}
|
||||
|
||||
const pathEntries = this.sessionManager.getBranch();
|
||||
@@ -2914,7 +2914,7 @@ export class AgentSession {
|
||||
let summaryDetails: unknown;
|
||||
if (options.summarize && entriesToSummarize.length > 0 && !extensionSummary) {
|
||||
const model = this.model!;
|
||||
const { apiKey, headers, env } = await this._getRequiredRequestAuth(model);
|
||||
const { apiKey, headers, env } = await this._getSummarizationRequestAuth(model);
|
||||
const branchSummarySettings = this.settingsManager.getBranchSummarySettings();
|
||||
const result = await generateBranchSummary(entriesToSummarize, {
|
||||
model,
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface GenerateBranchSummaryOptions {
|
||||
/** Model to use for summarization */
|
||||
model: Model<any>;
|
||||
/** API key for the model */
|
||||
apiKey: string;
|
||||
apiKey?: string;
|
||||
/** Request headers for the model */
|
||||
headers?: Record<string, string>;
|
||||
/** Provider-scoped environment values for the model */
|
||||
|
||||
@@ -78,6 +78,7 @@ const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [
|
||||
"app.tools.expand",
|
||||
"app.thinking.toggle",
|
||||
"app.editor.external",
|
||||
"app.message.copy",
|
||||
"app.message.followUp",
|
||||
"tui.input.submit",
|
||||
"tui.select.confirm",
|
||||
@@ -627,6 +628,11 @@ export class ExtensionRunner {
|
||||
this.shutdownHandler();
|
||||
}
|
||||
|
||||
getActiveTools(): string[] {
|
||||
this.assertActive();
|
||||
return this.runtime.getActiveTools();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ExtensionContext for use in event handlers and tool execution.
|
||||
* Context values are resolved at call time, so changes via bindCore/bindUI are reflected.
|
||||
|
||||
@@ -1432,6 +1432,8 @@ export interface ProviderConfig {
|
||||
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
|
||||
/** Convert credentials to API key string for the provider. */
|
||||
getApiKey(credentials: OAuthCredentials): string;
|
||||
/** Legacy synchronous credential-dependent model projection. */
|
||||
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import { wrapToolDefinition, wrapToolDefinitions } from "../tools/tool-definition-wrapper.ts";
|
||||
import { wrapToolDefinition } from "../tools/tool-definition-wrapper.ts";
|
||||
import type { ExtensionRunner } from "./runner.ts";
|
||||
import type { RegisteredTool } from "./types.ts";
|
||||
|
||||
@@ -15,7 +15,25 @@ import type { RegisteredTool } from "./types.ts";
|
||||
* Uses the runner's createContext() for consistent context across tools and event handlers.
|
||||
*/
|
||||
export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool {
|
||||
return wrapToolDefinition(registeredTool.definition, () => runner.createContext());
|
||||
const tool = wrapToolDefinition(registeredTool.definition, () => runner.createContext());
|
||||
const execute = tool.execute;
|
||||
return {
|
||||
...tool,
|
||||
execute: async (toolCallId, params, signal, onUpdate) => {
|
||||
const activeBefore = runner.getActiveTools();
|
||||
const result = await execute(toolCallId, params, signal, onUpdate);
|
||||
const activeAfter = runner.getActiveTools();
|
||||
if (!activeBefore.every((name) => activeAfter.includes(name))) return result;
|
||||
|
||||
const beforeNames = new Set(activeBefore);
|
||||
const addedToolNames = activeAfter.filter((name) => !beforeNames.has(name));
|
||||
if (addedToolNames.length === 0) return result;
|
||||
return {
|
||||
...result,
|
||||
addedToolNames: [...new Set([...(result.addedToolNames ?? []), ...addedToolNames])],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,8 +41,5 @@ export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: Exten
|
||||
* Uses the runner's createContext() for consistent context across tools and event handlers.
|
||||
*/
|
||||
export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: ExtensionRunner): AgentTool[] {
|
||||
return wrapToolDefinitions(
|
||||
registeredTools.map((registeredTool) => registeredTool.definition),
|
||||
() => runner.createContext(),
|
||||
);
|
||||
return registeredTools.map((tool) => wrapRegisteredTool(tool, runner));
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AppKeybindings {
|
||||
"app.thinking.toggle": true;
|
||||
"app.session.toggleNamedFilter": true;
|
||||
"app.editor.external": true;
|
||||
"app.message.copy": true;
|
||||
"app.message.followUp": true;
|
||||
"app.message.dequeue": true;
|
||||
"app.clipboard.pasteImage": true;
|
||||
@@ -95,6 +96,10 @@ export const KEYBINDINGS = {
|
||||
defaultKeys: "ctrl+g",
|
||||
description: "Open external editor",
|
||||
},
|
||||
"app.message.copy": {
|
||||
defaultKeys: "ctrl+x",
|
||||
description: "Copy message to clipboard",
|
||||
},
|
||||
"app.message.followUp": {
|
||||
defaultKeys: "alt+enter",
|
||||
description: "Queue follow-up message",
|
||||
@@ -105,18 +110,18 @@ export const KEYBINDINGS = {
|
||||
},
|
||||
"app.clipboard.pasteImage": {
|
||||
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
|
||||
description: "Paste image from clipboard",
|
||||
description: "Paste image from clipboard (text fallback)",
|
||||
},
|
||||
"app.session.new": { defaultKeys: [], description: "Start a new session" },
|
||||
"app.session.tree": { defaultKeys: [], description: "Open session tree" },
|
||||
"app.session.fork": { defaultKeys: [], description: "Fork current session" },
|
||||
"app.session.resume": { defaultKeys: [], description: "Resume a session" },
|
||||
"app.tree.foldOrUp": {
|
||||
defaultKeys: ["ctrl+left", "alt+left"],
|
||||
defaultKeys: process.platform === "darwin" ? ["alt+left", "ctrl+left"] : ["ctrl+left", "alt+left"],
|
||||
description: "Fold tree branch or move up",
|
||||
},
|
||||
"app.tree.unfoldOrDown": {
|
||||
defaultKeys: ["ctrl+right", "alt+right"],
|
||||
defaultKeys: process.platform === "darwin" ? ["alt+right", "ctrl+right"] : ["ctrl+right", "alt+right"],
|
||||
description: "Unfold tree branch or move down",
|
||||
},
|
||||
"app.tree.editLabel": {
|
||||
|
||||
@@ -98,13 +98,20 @@ const OpenAICompletionsCompatSchema = Type.Object({
|
||||
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
||||
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
||||
supportsStrictMode: Type.Optional(Type.Boolean()),
|
||||
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
|
||||
sessionAffinityFormat: Type.Optional(
|
||||
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
|
||||
),
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const OpenAIResponsesCompatSchema = Type.Object({
|
||||
supportsDeveloperRole: Type.Optional(Type.Boolean()),
|
||||
sendSessionIdHeader: Type.Optional(Type.Boolean()),
|
||||
sessionAffinityFormat: Type.Optional(
|
||||
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
|
||||
),
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
supportsToolSearch: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const AnthropicMessagesCompatSchema = Type.Object({
|
||||
@@ -113,6 +120,7 @@ const AnthropicMessagesCompatSchema = Type.Object({
|
||||
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
|
||||
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
|
||||
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
|
||||
supportsToolReferences: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
const ProviderCompatSchema = Type.Union([
|
||||
@@ -176,6 +184,7 @@ const ProviderConfigSchema = Type.Object({
|
||||
baseUrl: Type.Optional(Type.String({ minLength: 1 })),
|
||||
apiKey: Type.Optional(Type.String({ minLength: 1 })),
|
||||
api: Type.Optional(Type.String({ minLength: 1 })),
|
||||
oauth: Type.Optional(Type.Literal("radius")),
|
||||
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
compat: Type.Optional(ProviderCompatSchema),
|
||||
authHeader: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -18,6 +18,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
||||
openai: "gpt-5.5",
|
||||
"azure-openai-responses": "gpt-5.4",
|
||||
"openai-codex": "gpt-5.5",
|
||||
radius: "auto",
|
||||
nvidia: "nvidia/nemotron-3-super-120b-a12b",
|
||||
deepseek: "deepseek-v4-pro",
|
||||
google: "gemini-3.1-pro-preview",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
type Api,
|
||||
type ApiStreamOptions,
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
type Models,
|
||||
type ModelsApiStreamOptions,
|
||||
ModelsError,
|
||||
type ModelsRefreshOptions,
|
||||
type ModelsRefreshResult,
|
||||
type ModelsSimpleStreamOptions,
|
||||
type ModelsStore,
|
||||
type ModelsStreamTransforms,
|
||||
type MutableModels,
|
||||
type Provider,
|
||||
@@ -26,10 +29,11 @@ import {
|
||||
type SimpleStreamOptions,
|
||||
type StreamOptions,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
||||
import * as builtinProviderCatalog from "@earendil-works/pi-ai/providers/all";
|
||||
import { getAgentDir } from "../config.ts";
|
||||
import { AuthStorage as DefaultAuthStorage } from "./auth-storage.ts";
|
||||
import { ModelConfig } from "./model-config.ts";
|
||||
import { FileModelsStore, InMemoryCodingAgentModelsStore } from "./models-store.ts";
|
||||
import {
|
||||
type AuthStatus,
|
||||
type CompatibilityRequestConfig,
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
resolveConfiguredModelHeaders,
|
||||
validateExtensionProvider,
|
||||
} from "./provider-composer.ts";
|
||||
import { withRemoteCatalog } from "./remote-catalog-provider.ts";
|
||||
import { RuntimeCredentials } from "./runtime-credentials.ts";
|
||||
|
||||
interface ModelRuntimeSnapshot {
|
||||
@@ -55,6 +60,11 @@ export interface CreateModelRuntimeOptions {
|
||||
credentials?: CredentialStore;
|
||||
authPath?: string;
|
||||
modelsPath?: string | null;
|
||||
modelsStore?: ModelsStore;
|
||||
modelsStorePath?: string;
|
||||
allowModelNetwork?: boolean;
|
||||
modelRefreshTimeoutMs?: number;
|
||||
catalogBaseUrl?: string;
|
||||
}
|
||||
|
||||
export interface ModelRuntimeAuthOverrides {
|
||||
@@ -82,10 +92,12 @@ function mergeHeaders(
|
||||
export class ModelRuntime implements Models {
|
||||
private readonly models: MutableModels;
|
||||
private readonly credentials: RuntimeCredentials;
|
||||
private readonly builtins: ReadonlyMap<string, Provider>;
|
||||
private readonly defaultBuiltins: ReadonlyMap<string, Provider>;
|
||||
private readonly builtins = new Map<string, Provider>();
|
||||
private readonly extensionProviders = new Map<string, ProviderConfigInput>();
|
||||
private readonly compositionErrors = new Map<string, string>();
|
||||
private readonly modelsPath: string | undefined;
|
||||
private readonly allowModelNetwork: boolean;
|
||||
private config: ModelConfig;
|
||||
private snapshot: ModelRuntimeSnapshot = {
|
||||
all: [],
|
||||
@@ -101,13 +113,17 @@ export class ModelRuntime implements Models {
|
||||
credentials: RuntimeCredentials,
|
||||
config: ModelConfig,
|
||||
modelsPath: string | undefined,
|
||||
modelsStore: ModelsStore,
|
||||
providers: readonly Provider[],
|
||||
allowModelNetwork: boolean,
|
||||
) {
|
||||
this.credentials = credentials;
|
||||
this.config = config;
|
||||
this.modelsPath = modelsPath;
|
||||
this.builtins = new Map(providers.map((provider) => [provider.id, provider]));
|
||||
this.models = createModels({ credentials });
|
||||
this.allowModelNetwork = allowModelNetwork;
|
||||
this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider]));
|
||||
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
|
||||
this.models = createModels({ credentials, modelsStore });
|
||||
this.rebuildProviders();
|
||||
}
|
||||
|
||||
@@ -116,11 +132,55 @@ export class ModelRuntime implements Models {
|
||||
const modelsPath =
|
||||
options.modelsPath === null ? undefined : (options.modelsPath ?? join(getAgentDir(), "models.json"));
|
||||
const config = await ModelConfig.load(modelsPath);
|
||||
const runtime = new ModelRuntime(credentials, config, modelsPath, builtinProviders());
|
||||
await runtime.refreshAvailability();
|
||||
const modelsStore =
|
||||
options.modelsStore ??
|
||||
(modelsPath
|
||||
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
|
||||
: new InMemoryCodingAgentModelsStore());
|
||||
const providers = builtinProviderCatalog
|
||||
.builtinProviders()
|
||||
.map((provider) =>
|
||||
provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl),
|
||||
);
|
||||
const runtime = new ModelRuntime(
|
||||
credentials,
|
||||
config,
|
||||
modelsPath,
|
||||
modelsStore,
|
||||
providers,
|
||||
options.allowModelNetwork ?? process.env.PI_OFFLINE === undefined,
|
||||
);
|
||||
runtime.configureRadiusProviders();
|
||||
runtime.rebuildProviders();
|
||||
const controller = new AbortController();
|
||||
const timeout = runtime.allowModelNetwork
|
||||
? setTimeout(() => controller.abort(), options.modelRefreshTimeoutMs ?? 15_000)
|
||||
: undefined;
|
||||
try {
|
||||
await runtime.refresh({ allowNetwork: runtime.allowModelNetwork, signal: controller.signal });
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private configureRadiusProviders(): void {
|
||||
this.builtins.clear();
|
||||
for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider);
|
||||
for (const providerId of this.config.getProviderIds()) {
|
||||
const config = this.config.getProvider(providerId);
|
||||
if (config?.oauth !== "radius" || !config.baseUrl) continue;
|
||||
this.builtins.set(
|
||||
providerId,
|
||||
builtinProviderCatalog.radiusProvider({
|
||||
id: providerId,
|
||||
name: config.name ?? providerId,
|
||||
gateway: config.baseUrl.replace(/\/v1\/?$/u, ""),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private providerIds(): Set<string> {
|
||||
return new Set([...this.builtins.keys(), ...this.config.getProviderIds(), ...this.extensionProviders.keys()]);
|
||||
}
|
||||
@@ -319,7 +379,7 @@ export class ModelRuntime implements Models {
|
||||
};
|
||||
}
|
||||
|
||||
setRuntimeApiKey(providerId: string, apiKey: string): void {
|
||||
async setRuntimeApiKey(providerId: string, apiKey: string): Promise<void> {
|
||||
this.credentials.setRuntimeApiKey(providerId, apiKey);
|
||||
const auth = new Map(this.snapshot.auth).set(providerId, { type: "api_key", source: "runtime API key" });
|
||||
const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId);
|
||||
@@ -331,12 +391,12 @@ export class ModelRuntime implements Models {
|
||||
storedProviders,
|
||||
available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
|
||||
};
|
||||
void this.forceRefreshAvailability().catch(() => {});
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
}
|
||||
|
||||
removeRuntimeApiKey(providerId: string): void {
|
||||
async removeRuntimeApiKey(providerId: string): Promise<void> {
|
||||
this.credentials.removeRuntimeApiKey(providerId);
|
||||
void this.forceRefreshAvailability().catch(() => {});
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
}
|
||||
|
||||
listCredentials(): Promise<readonly CredentialInfo[]> {
|
||||
@@ -422,25 +482,38 @@ export class ModelRuntime implements Models {
|
||||
|
||||
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
|
||||
const credential = await this.models.login(providerId, type, interaction);
|
||||
await this.forceRefreshAvailability();
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
return credential;
|
||||
}
|
||||
|
||||
async logout(providerId: string): Promise<void> {
|
||||
await this.models.logout(providerId);
|
||||
await this.forceRefreshAvailability();
|
||||
// Reset credential-dependent compatibility projections before the unconfigured provider is skipped by refresh.
|
||||
this.recomposeProvider(providerId);
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
}
|
||||
|
||||
async reloadConfig(): Promise<void> {
|
||||
this.config = await ModelConfig.load(this.modelsPath);
|
||||
this.configureRadiusProviders();
|
||||
this.rebuildProviders();
|
||||
await this.forceRefreshAvailability();
|
||||
await this.refresh({ allowNetwork: this.allowModelNetwork });
|
||||
}
|
||||
|
||||
async refresh(providerId?: string): Promise<void> {
|
||||
await this.models.refresh(providerId);
|
||||
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
|
||||
// Published pi-ai builds before ModelsStore returned void and accepted a provider ID.
|
||||
// The fallback keeps source-mode CLI tests working without rebuilding workspace dependencies.
|
||||
const result = ((await this.models.refresh(options)) as ModelsRefreshResult | undefined) ?? {
|
||||
aborted: options.signal?.aborted ?? false,
|
||||
errors: new Map(),
|
||||
};
|
||||
this.updateModelSnapshot();
|
||||
await this.forceRefreshAvailability();
|
||||
try {
|
||||
await this.forceRefreshAvailability();
|
||||
} catch {
|
||||
// Availability errors are recorded by forceRefreshAvailability; refreshed models remain usable.
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
registerProvider(providerId: string, config: ProviderConfigInput): void {
|
||||
@@ -477,13 +550,13 @@ export class ModelRuntime implements Models {
|
||||
available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)),
|
||||
};
|
||||
}
|
||||
void this.forceRefreshAvailability().catch(() => {});
|
||||
void this.refresh({ allowNetwork: false });
|
||||
}
|
||||
|
||||
unregisterProvider(providerId: string): void {
|
||||
this.extensionProviders.delete(providerId);
|
||||
this.recomposeProvider(providerId);
|
||||
this.updateModelSnapshot();
|
||||
void this.forceRefreshAvailability().catch(() => {});
|
||||
void this.refresh({ allowNetwork: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { join } from "node:path";
|
||||
import type { Api, Model, ModelsStore } from "@earendil-works/pi-ai";
|
||||
import { getAgentDir } from "../config.ts";
|
||||
import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts";
|
||||
|
||||
type StoredModels = Record<string, Model<Api>[]>;
|
||||
|
||||
export class InMemoryCodingAgentModelsStore implements ModelsStore {
|
||||
private readonly models = new Map<string, readonly Model<Api>[]>();
|
||||
|
||||
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
|
||||
return this.models.get(providerId);
|
||||
}
|
||||
|
||||
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
|
||||
this.models.set(providerId, models);
|
||||
}
|
||||
|
||||
async delete(providerId: string): Promise<void> {
|
||||
this.models.delete(providerId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Locked JSON-backed storage for dynamically refreshed provider catalogs. */
|
||||
export class FileModelsStore implements ModelsStore {
|
||||
private readonly storage: AuthStorageBackend;
|
||||
|
||||
constructor(path: string = join(getAgentDir(), "models-store.json")) {
|
||||
this.storage = new FileAuthStorageBackend(path);
|
||||
}
|
||||
|
||||
private parse(content: string | undefined): StoredModels {
|
||||
return content ? (JSON.parse(content) as StoredModels) : {};
|
||||
}
|
||||
|
||||
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
|
||||
return this.storage.withLock((content) => ({
|
||||
result: this.parse(content)[providerId]?.map((model) => structuredClone(model)),
|
||||
}));
|
||||
}
|
||||
|
||||
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
|
||||
await this.storage.withLockAsync(async (content) => {
|
||||
const current = this.parse(content);
|
||||
current[providerId] = models.map((model) => structuredClone(model));
|
||||
return { result: undefined, next: JSON.stringify(current, null, 2) };
|
||||
});
|
||||
}
|
||||
|
||||
async delete(providerId: string): Promise<void> {
|
||||
await this.storage.withLockAsync(async (content) => {
|
||||
const current = this.parse(content);
|
||||
delete current[providerId];
|
||||
return { result: undefined, next: JSON.stringify(current, null, 2) };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1805,11 +1805,16 @@ export class DefaultPackageManager implements PackageManager {
|
||||
if (!existsSync(installRoot)) {
|
||||
return;
|
||||
}
|
||||
if (this.getPackageManagerName() === "bun") {
|
||||
const packageManagerName = this.getPackageManagerName();
|
||||
if (packageManagerName === "bun") {
|
||||
await this.runNpmCommand(["uninstall", source.name, "--cwd", installRoot]);
|
||||
return;
|
||||
}
|
||||
await this.runNpmCommand(["uninstall", source.name, "--prefix", installRoot]);
|
||||
const args = ["uninstall", source.name, "--prefix", installRoot];
|
||||
if (packageManagerName !== "pnpm") {
|
||||
args.push("--legacy-peer-deps");
|
||||
}
|
||||
await this.runNpmCommand(args);
|
||||
}
|
||||
|
||||
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ExtensionOAuthConfig {
|
||||
login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
|
||||
refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials>;
|
||||
getApiKey(credentials: OAuthCredentials): string;
|
||||
modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
|
||||
}
|
||||
|
||||
/** Input type for the extension registerProvider API. */
|
||||
@@ -161,6 +162,9 @@ function applyModelsJson(
|
||||
config: ModelsJsonProvider | undefined,
|
||||
): Model<Api>[] {
|
||||
if (!config) return [...baseModels];
|
||||
if (config.oauth && !config.baseUrl) {
|
||||
throw new Error(`Provider ${providerId}: "baseUrl" is required when "oauth" is set.`);
|
||||
}
|
||||
const hasOverrides = config.modelOverrides && Object.keys(config.modelOverrides).length > 0;
|
||||
if (
|
||||
!config.models?.length &&
|
||||
@@ -169,6 +173,7 @@ function applyModelsJson(
|
||||
!config.compat &&
|
||||
!hasOverrides &&
|
||||
!config.apiKey &&
|
||||
!config.oauth &&
|
||||
config.authHeader === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
@@ -178,7 +183,7 @@ function applyModelsJson(
|
||||
|
||||
const models: Model<Api>[] = baseModels.map((model) => ({
|
||||
...model,
|
||||
baseUrl: config.baseUrl ?? model.baseUrl,
|
||||
baseUrl: config.oauth === "radius" ? model.baseUrl : (config.baseUrl ?? model.baseUrl),
|
||||
compat: mergeCompat(model.compat, config.compat),
|
||||
}));
|
||||
for (const definition of config.models ?? []) {
|
||||
@@ -409,15 +414,19 @@ export function composeModelProvider(
|
||||
extension: ProviderConfigInput | undefined,
|
||||
): Provider {
|
||||
const config = modelConfig.getProvider(providerId);
|
||||
let extensionOAuthCredential: OAuthCredentials | undefined;
|
||||
// models.json modelOverrides are the topmost user-config layer: they apply once,
|
||||
// after custom-model upserts and extension model replacement.
|
||||
const getModels = () =>
|
||||
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension).map(
|
||||
(model) => {
|
||||
const override = config?.modelOverrides?.[model.id];
|
||||
return override ? applyModelOverride(model, override) : model;
|
||||
},
|
||||
);
|
||||
// after custom-model upserts, extension model replacement, and legacy OAuth projection.
|
||||
const getModels = () => {
|
||||
let models = applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension);
|
||||
if (extensionOAuthCredential && extension?.oauth?.modifyModels) {
|
||||
models = extension.oauth.modifyModels(models, extensionOAuthCredential);
|
||||
}
|
||||
return models.map((model) => {
|
||||
const override = config?.modelOverrides?.[model.id];
|
||||
return override ? applyModelOverride(model, override) : model;
|
||||
});
|
||||
};
|
||||
// Validate eagerly so registration/reload reports structural errors immediately.
|
||||
getModels();
|
||||
const apiKey = composeApiKeyAuth(providerId, base, config, extension);
|
||||
@@ -454,7 +463,13 @@ export function composeModelProvider(
|
||||
headers: base?.headers,
|
||||
auth: { ...(apiKey ? { apiKey } : {}), ...(oauth ? { oauth } : {}) },
|
||||
getModels,
|
||||
refreshModels: base?.refreshModels ? () => base.refreshModels!() : undefined,
|
||||
refreshModels:
|
||||
base?.refreshModels || extension?.oauth?.modifyModels
|
||||
? async (context) => {
|
||||
await base?.refreshModels?.(context);
|
||||
extensionOAuthCredential = context.credential?.type === "oauth" ? context.credential : undefined;
|
||||
}
|
||||
: undefined,
|
||||
filterModels: base?.filterModels
|
||||
? (models, credential: Credential | undefined) => base.filterModels!(models, credential)
|
||||
: undefined,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const RADIUS_PROVIDER_ID = "radius";
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
|
||||
|
||||
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
|
||||
|
||||
function mergeModels(baseline: readonly Model<Api>[], dynamic: readonly Model<Api>[]): Model<Api>[] {
|
||||
const merged = [...baseline];
|
||||
for (const model of dynamic) {
|
||||
const index = merged.findIndex((entry) => entry.id === model.id);
|
||||
if (index >= 0) merged[index] = model;
|
||||
else merged.push(model);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
|
||||
const entries = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === "object" && value !== null && "models" in value && Array.isArray(value.models)
|
||||
? value.models
|
||||
: undefined;
|
||||
if (!entries) throw new Error(`Invalid model catalog for provider "${providerId}"`);
|
||||
return entries
|
||||
.filter((entry): entry is Model<Api> => typeof entry === "object" && entry !== null && "id" in entry)
|
||||
.map((model) => ({ ...model, provider: providerId }));
|
||||
}
|
||||
|
||||
/** Add a persisted pi.dev catalog overlay to a static built-in provider. */
|
||||
export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL): Provider {
|
||||
let dynamicModels: readonly Model<Api>[] = [];
|
||||
let inflightRefresh: Promise<void> | undefined;
|
||||
|
||||
return {
|
||||
...provider,
|
||||
getModels: () => mergeModels(provider.getModels(), dynamicModels),
|
||||
refreshModels: (context) => {
|
||||
inflightRefresh ??= (async () => {
|
||||
try {
|
||||
const stored = await context.store.read();
|
||||
if (stored) dynamicModels = stored.filter((model) => model.provider === provider.id);
|
||||
if (!context.allowNetwork || context.signal?.aborted) return;
|
||||
|
||||
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: context.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
|
||||
}
|
||||
const refreshed = parseCatalog(provider.id, await response.json());
|
||||
if (context.signal?.aborted) return;
|
||||
dynamicModels = refreshed;
|
||||
await context.store.write(refreshed);
|
||||
} finally {
|
||||
inflightRefresh = undefined;
|
||||
}
|
||||
})();
|
||||
return inflightRefresh;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,14 +36,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
||||
contextFiles: providedContextFiles,
|
||||
skills: providedSkills,
|
||||
} = options;
|
||||
const resolvedCwd = cwd;
|
||||
const promptCwd = resolvedCwd.replace(/\\/g, "/");
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
const date = `${year}-${month}-${day}`;
|
||||
const promptCwd = cwd.replace(/\\/g, "/");
|
||||
|
||||
const appendSection = appendSystemPrompt ? `\n\n${appendSystemPrompt}` : "";
|
||||
|
||||
@@ -73,8 +66,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
// Add date and working directory last
|
||||
prompt += `\nCurrent date: ${date}`;
|
||||
prompt += `\nCurrent working directory: ${promptCwd}`;
|
||||
|
||||
return prompt;
|
||||
@@ -165,8 +156,6 @@ Pi documentation (read only when the user asks about pi itself, its SDK, extensi
|
||||
prompt += formatSkillsForPrompt(skills);
|
||||
}
|
||||
|
||||
// Add date and working directory last
|
||||
prompt += `\nCurrent date: ${date}`;
|
||||
prompt += `\nCurrent working directory: ${promptCwd}`;
|
||||
|
||||
return prompt;
|
||||
|
||||
@@ -707,7 +707,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
message: "--api-key requires a model to be specified via --model, --provider/--model, or --models",
|
||||
});
|
||||
} else {
|
||||
modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
|
||||
await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
|
||||
await services.modelRuntime.getAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class CustomEditor extends Editor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for paste image keybinding
|
||||
// Check for clipboard paste keybinding
|
||||
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
|
||||
this.onPasteImage?.();
|
||||
return;
|
||||
|
||||
@@ -175,6 +175,16 @@ export class LoginDialogComponent extends Container implements Focusable {
|
||||
});
|
||||
}
|
||||
|
||||
/** Show informational text before another login step. */
|
||||
showDetails(lines: string[]): void {
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
for (const line of lines) {
|
||||
this.contentContainer.addChild(new Text(line, 1, 0));
|
||||
}
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
/** Show provider-owned information and links without starting an auth callback flow. */
|
||||
showInfo(message: string, links: readonly AuthInfoLink[] = [], showCloseHint = false): void {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
|
||||
@@ -61,6 +61,9 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
private scope: ModelScope = "all";
|
||||
private scopeText?: Text;
|
||||
private scopeHintText?: Text;
|
||||
private readonly refreshAbortController = new AbortController();
|
||||
private refreshTimeout?: ReturnType<typeof setTimeout>;
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
@@ -123,47 +126,20 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Load models and do initial render
|
||||
this.loadModels().then(() => {
|
||||
if (initialSearchInput) {
|
||||
this.filterModels(initialSearchInput);
|
||||
} else {
|
||||
this.updateList();
|
||||
}
|
||||
// Request re-render after models are loaded
|
||||
this.tui.requestRender();
|
||||
});
|
||||
// Render the current snapshot immediately, then refresh in the background.
|
||||
this.loadModelsFromSnapshot();
|
||||
if (initialSearchInput) this.filterModels(initialSearchInput);
|
||||
else this.updateList();
|
||||
this.tui.requestRender();
|
||||
void this.refreshModels();
|
||||
}
|
||||
|
||||
private async loadModels(): Promise<void> {
|
||||
let models: ModelItem[];
|
||||
|
||||
// Refresh to pick up any changes to models.json
|
||||
await this.modelRuntime.refresh();
|
||||
|
||||
// Check for models.json errors
|
||||
const loadError = this.modelRuntime.getError();
|
||||
if (loadError) {
|
||||
this.errorMessage = loadError;
|
||||
}
|
||||
|
||||
// Load available models (built-in models still work even if models.json failed)
|
||||
try {
|
||||
const availableModels = await this.modelRuntime.getAvailable();
|
||||
models = availableModels.map((model: Model<any>) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
model,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.allModels = [];
|
||||
this.scopedModelItems = [];
|
||||
this.activeModels = [];
|
||||
this.filteredModels = [];
|
||||
this.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return;
|
||||
}
|
||||
|
||||
private loadModelsFromSnapshot(): void {
|
||||
const models = this.modelRuntime.getAvailableSnapshot().map((model: Model<any>) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
model,
|
||||
}));
|
||||
this.allModels = this.sortModels(models);
|
||||
this.scopedModels = this.scopedModels.map((scoped) => {
|
||||
const refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id);
|
||||
@@ -181,6 +157,37 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
currentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
||||
}
|
||||
|
||||
private async refreshModels(): Promise<void> {
|
||||
const timeoutMs = 15_000;
|
||||
let timedOut = false;
|
||||
this.refreshTimeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
this.refreshAbortController.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const result = await this.modelRuntime.refresh({ signal: this.refreshAbortController.signal });
|
||||
if (this.closed) return;
|
||||
if (result.aborted && timedOut) {
|
||||
this.errorMessage = "Model refresh timed out; showing cached models.";
|
||||
} else if (result.errors.size > 0) {
|
||||
this.errorMessage = `Model refresh failed for: ${[...result.errors.keys()].join(", ")}`;
|
||||
} else {
|
||||
this.errorMessage = this.modelRuntime.getError();
|
||||
}
|
||||
this.loadModelsFromSnapshot();
|
||||
this.filterModels(this.searchInput.getValue());
|
||||
this.tui.requestRender();
|
||||
} finally {
|
||||
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.closed = true;
|
||||
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
|
||||
this.refreshAbortController.abort();
|
||||
}
|
||||
|
||||
private sortModels(models: ModelItem[]): ModelItem[] {
|
||||
const sorted = [...models];
|
||||
// Sort: current model first, then by provider
|
||||
@@ -316,6 +323,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
this.close();
|
||||
this.onCancelCallback();
|
||||
}
|
||||
// Pass everything else to search input
|
||||
@@ -326,6 +334,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
}
|
||||
|
||||
private handleSelect(model: Model<any>): void {
|
||||
this.close();
|
||||
// Save as new default
|
||||
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
||||
this.onSelectCallback(model);
|
||||
|
||||
@@ -122,6 +122,7 @@ class TreeList implements Component {
|
||||
|
||||
public onSelect?: (entryId: string) => void;
|
||||
public onCancel?: () => void;
|
||||
public onCopy?: (text: string | undefined) => void;
|
||||
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
|
||||
|
||||
constructor(
|
||||
@@ -623,6 +624,11 @@ class TreeList implements Component {
|
||||
return this.filteredNodes[this.selectedIndex]?.node;
|
||||
}
|
||||
|
||||
copySelected(): void {
|
||||
const node = this.getSelectedNode();
|
||||
this.onCopy?.(node ? this.getEntryCopyText(node) : undefined);
|
||||
}
|
||||
|
||||
updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void {
|
||||
for (const flatNode of this.flatNodes) {
|
||||
if (flatNode.node.entry.id === entryId) {
|
||||
@@ -871,19 +877,49 @@ class TreeList implements Component {
|
||||
}
|
||||
|
||||
private extractContent(content: unknown): string {
|
||||
const maxLen = 200;
|
||||
if (typeof content === "string") return content.slice(0, maxLen);
|
||||
if (Array.isArray(content)) {
|
||||
let result = "";
|
||||
for (const c of content) {
|
||||
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
|
||||
result += (c as { text: string }).text;
|
||||
if (result.length >= maxLen) return result.slice(0, maxLen);
|
||||
}
|
||||
return this.extractFullContent(content).slice(0, 200);
|
||||
}
|
||||
|
||||
private extractFullContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
let result = "";
|
||||
for (const block of content) {
|
||||
if (typeof block === "object" && block !== null && "type" in block && block.type === "text") {
|
||||
result += (block as { text: string }).text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return "";
|
||||
return result;
|
||||
}
|
||||
|
||||
private getEntryCopyText(node: SessionTreeNode): string | undefined {
|
||||
const entry = node.entry;
|
||||
let text: string | undefined;
|
||||
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
if (entry.message.role === "bashExecution") {
|
||||
text = entry.message.command;
|
||||
} else if ("content" in entry.message) {
|
||||
text = this.extractFullContent(entry.message.content);
|
||||
if (!text && entry.message.role === "assistant") {
|
||||
text = entry.message.errorMessage;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "custom_message":
|
||||
text = this.extractFullContent(entry.content);
|
||||
break;
|
||||
case "compaction":
|
||||
text = entry.summary;
|
||||
break;
|
||||
case "branch_summary":
|
||||
text = entry.summary;
|
||||
break;
|
||||
}
|
||||
|
||||
return text?.trim() ? text : undefined;
|
||||
}
|
||||
|
||||
private hasTextContent(content: unknown): boolean {
|
||||
@@ -990,6 +1026,8 @@ class TreeList implements Component {
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.node.entry.id);
|
||||
}
|
||||
} else if (kb.matches(keyData, "app.message.copy")) {
|
||||
this.copySelected();
|
||||
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
if (this.searchQuery) {
|
||||
this.searchQuery = "";
|
||||
@@ -1180,6 +1218,7 @@ const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: b
|
||||
{ keys: ["tui.select.up", "tui.select.down"], label: "move" },
|
||||
{ keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
|
||||
{ keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
|
||||
{ keys: ["app.message.copy"], label: "copy" },
|
||||
{ keys: ["app.tree.editLabel"], label: "label" },
|
||||
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
|
||||
{
|
||||
@@ -1292,6 +1331,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
|
||||
private labelInputContainer: Container;
|
||||
private treeContainer: Container;
|
||||
private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
|
||||
public onCopy?: (text: string | undefined) => void;
|
||||
|
||||
// Focusable implementation - propagate to labelInput when active for IME cursor positioning
|
||||
private _focused = false;
|
||||
@@ -1324,6 +1364,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
|
||||
this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode);
|
||||
this.treeList.onSelect = onSelect;
|
||||
this.treeList.onCancel = onCancel;
|
||||
this.treeList.onCopy = (text) => this.onCopy?.(text);
|
||||
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
|
||||
|
||||
this.treeContainer = new Container();
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
getAgentDir,
|
||||
getAuthPath,
|
||||
getDebugLogPath,
|
||||
getDocsPath,
|
||||
getShareViewerUrl,
|
||||
VERSION,
|
||||
} from "../../config.ts";
|
||||
@@ -86,7 +87,7 @@ import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
||||
import { copyToClipboard } from "../../utils/clipboard.ts";
|
||||
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||
import { parseGitUrl } from "../../utils/git.ts";
|
||||
import { getCwdRelativePath } from "../../utils/paths.ts";
|
||||
@@ -745,7 +746,7 @@ export class InteractiveMode {
|
||||
rawKeyHint("!!", "to run bash (no context)"),
|
||||
hint("app.message.followUp", "to queue follow-up"),
|
||||
hint("app.message.dequeue", "to edit all queued messages"),
|
||||
hint("app.clipboard.pasteImage", "to paste image"),
|
||||
hint("app.clipboard.pasteImage", "to paste image (with text fallback)"),
|
||||
rawKeyHint("drop files", "to attach"),
|
||||
].join("\n");
|
||||
const compactInstructions = [
|
||||
@@ -2558,6 +2559,7 @@ export class InteractiveMode {
|
||||
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
|
||||
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
|
||||
this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor());
|
||||
this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand());
|
||||
this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
|
||||
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
|
||||
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
|
||||
@@ -2573,29 +2575,33 @@ export class InteractiveMode {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clipboard image paste (triggered on Ctrl+V)
|
||||
// Handle clipboard paste (triggered on Ctrl+V). Images are attached by path;
|
||||
// otherwise, paste plain text from the system clipboard.
|
||||
this.defaultEditor.onPasteImage = () => {
|
||||
this.handleClipboardImagePaste();
|
||||
void this.handleClipboardPaste();
|
||||
};
|
||||
}
|
||||
|
||||
private async handleClipboardImagePaste(): Promise<void> {
|
||||
private async handleClipboardPaste(): Promise<void> {
|
||||
try {
|
||||
const image = await readClipboardImage();
|
||||
if (!image) {
|
||||
if (image) {
|
||||
const tmpDir = os.tmpdir();
|
||||
const ext = extensionForImageMimeType(image.mimeType) ?? "png";
|
||||
const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
|
||||
const filePath = path.join(tmpDir, fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(image.bytes));
|
||||
|
||||
this.editor.insertTextAtCursor?.(filePath);
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
const tmpDir = os.tmpdir();
|
||||
const ext = extensionForImageMimeType(image.mimeType) ?? "png";
|
||||
const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
|
||||
const filePath = path.join(tmpDir, fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(image.bytes));
|
||||
|
||||
// Insert file path directly
|
||||
this.editor.insertTextAtCursor?.(filePath);
|
||||
this.ui.requestRender();
|
||||
const text = await readClipboardText();
|
||||
if (text) {
|
||||
this.editor.insertTextAtCursor?.(text);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore clipboard errors (may not have permission, etc.)
|
||||
}
|
||||
@@ -4681,6 +4687,18 @@ export class InteractiveMode {
|
||||
initialSelectedId,
|
||||
initialFilterMode,
|
||||
);
|
||||
selector.onCopy = async (text) => {
|
||||
if (!text) {
|
||||
this.showError("Selected entry has no text to copy");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
this.showStatus("Copied selected message to clipboard");
|
||||
} catch (error) {
|
||||
this.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
return { component: selector, focus: selector };
|
||||
});
|
||||
}
|
||||
@@ -4851,8 +4869,8 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void {
|
||||
const subscriptionLabel = "Use a subscription";
|
||||
const apiKeyLabel = "Use an API key";
|
||||
const subscriptionLabel = "Sign in with an account";
|
||||
const apiKeyLabel = "Sign in with an API key";
|
||||
const availableAuthTypes = providerOptions
|
||||
? new Set(providerOptions.map((provider) => provider.authType))
|
||||
: new Set<AuthSelectorProvider["authType"]>(["oauth", "api_key"]);
|
||||
@@ -5083,6 +5101,14 @@ export class InteractiveMode {
|
||||
providerName,
|
||||
);
|
||||
|
||||
if (providerId === "amazon-bedrock") {
|
||||
dialog.showDetails([
|
||||
theme.fg("text", "You can also use an AWS profile, IAM keys, or role-based credentials."),
|
||||
theme.fg("muted", "See:"),
|
||||
theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`),
|
||||
]);
|
||||
}
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(dialog);
|
||||
this.ui.setFocus(dialog);
|
||||
@@ -5698,6 +5724,7 @@ export class InteractiveMode {
|
||||
const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle");
|
||||
const externalEditor = this.getAppKeyDisplay("app.editor.external");
|
||||
const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward");
|
||||
const copyMessage = this.getAppKeyDisplay("app.message.copy");
|
||||
const followUp = this.getAppKeyDisplay("app.message.followUp");
|
||||
const dequeue = this.getAppKeyDisplay("app.message.dequeue");
|
||||
const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
|
||||
@@ -5741,9 +5768,10 @@ export class InteractiveMode {
|
||||
| \`${expandTools}\` | Toggle tool output expansion |
|
||||
| \`${toggleThinking}\` | Toggle thinking block visibility |
|
||||
| \`${externalEditor}\` | Edit message in external editor |
|
||||
| \`${copyMessage}\` | Copy last assistant message |
|
||||
| \`${followUp}\` | Queue follow-up message |
|
||||
| \`${dequeue}\` | Restore queued messages |
|
||||
| \`${pasteImage}\` | Paste image from clipboard |
|
||||
| \`${pasteImage}\` | Paste image or text from clipboard |
|
||||
| \`/\` | Slash commands |
|
||||
| \`!\` | Run bash command |
|
||||
| \`!!\` | Run bash command (excluded from context) |
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname, join } from "path";
|
||||
import { pathToFileURL } from "url";
|
||||
|
||||
export type ClipboardModule = {
|
||||
getText: () => Promise<string>;
|
||||
setText: (text: string) => Promise<void>;
|
||||
hasImage: () => boolean;
|
||||
getImageBinary: () => Promise<Array<number>>;
|
||||
|
||||
@@ -32,6 +32,20 @@ function emitOsc52(text: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Read plain text from the system clipboard, if native clipboard access is available. */
|
||||
export async function readClipboardText(): Promise<string | null> {
|
||||
if (!clipboard) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await clipboard.getText();
|
||||
return text || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
let copied = false;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboar
|
||||
type ClipboardRequire = (id: string) => unknown;
|
||||
|
||||
const fakeClipboard: ClipboardModule = {
|
||||
getText: async () => "",
|
||||
setText: async () => {},
|
||||
hasImage: () => true,
|
||||
getImageBinary: async () => [1, 2, 3],
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { execSync, spawn } from "child_process";
|
||||
import { platform } from "os";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { copyToClipboard } from "../src/utils/clipboard.ts";
|
||||
import { copyToClipboard, readClipboardText } from "../src/utils/clipboard.ts";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
clipboard: {
|
||||
getText: vi.fn<() => Promise<string>>(),
|
||||
setText: vi.fn<(text: string) => Promise<void>>(),
|
||||
},
|
||||
execSync: vi.fn(),
|
||||
@@ -59,6 +60,7 @@ beforeEach(() => {
|
||||
vi.stubEnv("MOSH_CONNECTION", "");
|
||||
stdoutWrites = [];
|
||||
nativeResolved = false;
|
||||
mocks.clipboard.getText.mockReset();
|
||||
mocks.clipboard.setText.mockReset();
|
||||
mocks.execSync.mockReset();
|
||||
mocks.spawn.mockReset();
|
||||
@@ -66,6 +68,7 @@ beforeEach(() => {
|
||||
mocks.isWaylandSession.mockReset();
|
||||
mockedPlatform.mockReturnValue("darwin");
|
||||
mocks.isWaylandSession.mockReturnValue(false);
|
||||
mocks.clipboard.getText.mockResolvedValue("");
|
||||
mocks.clipboard.setText.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
nativeResolved = true;
|
||||
@@ -86,6 +89,21 @@ afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("readClipboardText", () => {
|
||||
test("returns native clipboard text", async () => {
|
||||
mocks.clipboard.getText.mockResolvedValue("clipboard text");
|
||||
|
||||
await expect(readClipboardText()).resolves.toBe("clipboard text");
|
||||
});
|
||||
|
||||
test("returns null for empty or unavailable clipboard text", async () => {
|
||||
await expect(readClipboardText()).resolves.toBeNull();
|
||||
|
||||
mocks.clipboard.getText.mockRejectedValue(new Error("clipboard unavailable"));
|
||||
await expect(readClipboardText()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("copyToClipboard", () => {
|
||||
test("local native success skips OSC 52 and shell fallbacks", async () => {
|
||||
await copyToClipboard("hello");
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { InMemoryModelsStore, type Model } from "@earendil-works/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
|
||||
function model(id: string): Model<"openai-completions"> {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
api: "openai-completions",
|
||||
provider: "extension-oauth",
|
||||
baseUrl: "https://example.test/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
}
|
||||
|
||||
describe("legacy extension OAuth modifyModels", () => {
|
||||
it("applies the synchronous projection after async credential initialization", async () => {
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials: AuthStorage.inMemory({
|
||||
"extension-oauth": {
|
||||
type: "oauth",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
}),
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
modelsPath: null,
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
runtime.registerProvider("extension-oauth", {
|
||||
baseUrl: "https://example.test/v1",
|
||||
api: "openai-completions",
|
||||
models: [model("base")],
|
||||
oauth: {
|
||||
name: "Extension OAuth",
|
||||
login: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
refreshToken: async (credential) => credential,
|
||||
getApiKey: (credential) => credential.access,
|
||||
modifyModels: (models, credential) =>
|
||||
credential.access === "access" ? [...models, model("credential-model")] : models,
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.refresh({ allowNetwork: false });
|
||||
expect(runtime.getModel("extension-oauth", "base")).toBeDefined();
|
||||
expect(runtime.getModel("extension-oauth", "credential-model")).toBeDefined();
|
||||
|
||||
await runtime.logout("extension-oauth");
|
||||
expect(runtime.getModel("extension-oauth", "credential-model")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { FileModelsStore } from "../src/core/models-store.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of tempDirs.splice(0)) {
|
||||
if (existsSync(path)) rmSync(path, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function model(provider: string, id: string): Model<"openai-completions"> {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
api: "openai-completions",
|
||||
provider,
|
||||
baseUrl: "https://example.test/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FileModelsStore", () => {
|
||||
it("persists provider catalogs without replacing unrelated providers", async () => {
|
||||
const dir = join(tmpdir(), `pi-models-store-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
tempDirs.push(dir);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const path = join(dir, "models-store.json");
|
||||
const store = new FileModelsStore(path);
|
||||
|
||||
await store.write("one", [model("one", "m1")]);
|
||||
await store.write("two", [model("two", "m2")]);
|
||||
|
||||
const reloaded = new FileModelsStore(path);
|
||||
expect((await reloaded.read("one"))?.map((entry) => entry.id)).toEqual(["m1"]);
|
||||
expect((await reloaded.read("two"))?.map((entry) => entry.id)).toEqual(["m2"]);
|
||||
|
||||
await reloaded.delete("one");
|
||||
expect(await reloaded.read("one")).toBeUndefined();
|
||||
expect((await reloaded.read("two"))?.map((entry) => entry.id)).toEqual(["m2"]);
|
||||
});
|
||||
});
|
||||
@@ -722,6 +722,19 @@ Content`,
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass legacy peer deps when uninstalling npm packages", async () => {
|
||||
mkdirSync(join(agentDir, "npm"), { recursive: true });
|
||||
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
|
||||
|
||||
await packageManager.remove("npm:@scope/pkg");
|
||||
|
||||
expect(runCommandSpy).toHaveBeenCalledWith(
|
||||
"npm",
|
||||
["uninstall", "@scope/pkg", "--prefix", join(agentDir, "npm"), "--legacy-peer-deps"],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should use bun --cwd for npm package installs", async () => {
|
||||
settingsManager = SettingsManager.inMemory({
|
||||
npmCommand: ["mise", "exec", "bun@1", "--", "bun"],
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { InMemoryModelsStore } from "@earendil-works/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../src/core/model-runtime.ts";
|
||||
import { RADIUS_PROVIDER_ID } from "../src/core/radius.ts";
|
||||
|
||||
function radiusOAuthCredential(gatewayBaseUrl: string) {
|
||||
return {
|
||||
type: "oauth" as const,
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
gatewayConfig: radiusConfig(gatewayBaseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function radiusConfig(baseUrl: string) {
|
||||
return {
|
||||
baseUrl,
|
||||
models: [
|
||||
{
|
||||
id: "auto",
|
||||
name: "Radius Auto",
|
||||
reasoning: false,
|
||||
input: ["text" as const],
|
||||
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `pi-test-radius-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (tempDir && existsSync(tempDir)) rmSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
describe("Radius provider", () => {
|
||||
it("restores the legacy credential catalog without network access", async () => {
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials: AuthStorage.inMemory({
|
||||
[RADIUS_PROVIDER_ID]: radiusOAuthCredential("https://radius.example.com/v1"),
|
||||
}),
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
modelsPath: null,
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
|
||||
const model = runtime.getModel(RADIUS_PROVIDER_ID, "auto");
|
||||
expect(model).toMatchObject({ api: "pi-messages", baseUrl: "https://radius.example.com/v1" });
|
||||
expect(runtime.getProvider(RADIUS_PROVIDER_ID)?.name).toBe("Radius");
|
||||
expect(runtime.hasConfiguredAuth(RADIUS_PROVIDER_ID)).toBe(true);
|
||||
});
|
||||
|
||||
it("fetches and stores the catalog for configured Radius auth", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify(radiusConfig("https://radius.example.com/v1")), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const modelsStore = new InMemoryModelsStore();
|
||||
const credentials = AuthStorage.inMemory({
|
||||
[RADIUS_PROVIDER_ID]: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
});
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials,
|
||||
modelsStore,
|
||||
modelsPath: null,
|
||||
allowModelNetwork: true,
|
||||
});
|
||||
|
||||
expect(runtime.getModel(RADIUS_PROVIDER_ID, "auto")).toBeDefined();
|
||||
expect(await modelsStore.read(RADIUS_PROVIDER_ID)).toHaveLength(1);
|
||||
expect(vi.mocked(fetch).mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer access-token" });
|
||||
});
|
||||
|
||||
it("does not fetch or expose Radius models without configured auth", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials: AuthStorage.inMemory(),
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
modelsPath: null,
|
||||
allowModelNetwork: true,
|
||||
});
|
||||
|
||||
expect(runtime.getModels(RADIUS_PROVIDER_ID)).toEqual([]);
|
||||
expect(fetchSpy.mock.calls.some(([url]) => String(url).includes("radius.pi.dev/v1/config"))).toBe(false);
|
||||
});
|
||||
|
||||
it("supports custom Radius gateways from models.json", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify(radiusConfig("http://localhost:8788/v1")), { status: 200 }),
|
||||
);
|
||||
const modelsPath = join(tempDir, "models.json");
|
||||
writeFileSync(
|
||||
modelsPath,
|
||||
JSON.stringify({
|
||||
providers: { "radius-dev": { name: "Radius (dev)", baseUrl: "http://localhost:8788", oauth: "radius" } },
|
||||
}),
|
||||
);
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials: AuthStorage.inMemory({
|
||||
"radius-dev": {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60 * 60 * 1000,
|
||||
},
|
||||
}),
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
modelsPath,
|
||||
allowModelNetwork: true,
|
||||
});
|
||||
|
||||
expect(runtime.getModel("radius-dev", "auto")).toMatchObject({
|
||||
api: "pi-messages",
|
||||
baseUrl: "http://localhost:8788/v1",
|
||||
});
|
||||
expect(runtime.getProvider("radius-dev")?.name).toBe("Radius (dev)");
|
||||
});
|
||||
|
||||
it("requires baseUrl for custom Radius gateways", async () => {
|
||||
const modelsPath = join(tempDir, "models.json");
|
||||
writeFileSync(modelsPath, JSON.stringify({ providers: { "radius-dev": { oauth: "radius" } } }));
|
||||
const runtime = await ModelRuntime.create({
|
||||
credentials: AuthStorage.inMemory(),
|
||||
modelsStore: new InMemoryModelsStore(),
|
||||
modelsPath,
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
|
||||
expect(runtime.getError()).toContain('"baseUrl" is required when "oauth" is set');
|
||||
});
|
||||
});
|
||||
+12
@@ -84,6 +84,18 @@ describe("LoginDialogComponent OAuth prompts", () => {
|
||||
expect(output).toContain("Press Enter to continue:");
|
||||
});
|
||||
|
||||
test("preserves setup details when showing a prompt", () => {
|
||||
const dialog = createDialog();
|
||||
|
||||
dialog.showDetails(["AWS credential setup:", "providers.md"]);
|
||||
dialog.showPrompt("Enter API key:");
|
||||
|
||||
const output = renderDialog(dialog).join("\n");
|
||||
expect(output).toContain("AWS credential setup:");
|
||||
expect(output).toContain("providers.md");
|
||||
expect(output).toContain("Enter API key:");
|
||||
});
|
||||
|
||||
test("keeps previous manual input stable when a later prompt is active", async () => {
|
||||
const dialog = createDialog();
|
||||
|
||||
|
||||
+56
@@ -66,6 +66,62 @@ describe("extension active tools next-turn refresh", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("records additive active tool changes on the current tool result", async () => {
|
||||
const extensionFactories: ExtensionFactory[] = [
|
||||
(pi) => {
|
||||
pi.registerTool({
|
||||
name: "load_more_tools",
|
||||
label: "Load More Tools",
|
||||
description: "Load more tools",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => {
|
||||
pi.setActiveTools([...pi.getActiveTools(), "after_load"]);
|
||||
return {
|
||||
content: [{ type: "text", text: "loaded" }],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "after_load",
|
||||
label: "After Load",
|
||||
description: "Tool available after loading",
|
||||
parameters: Type.Object({}),
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "after" }],
|
||||
details: {},
|
||||
}),
|
||||
});
|
||||
},
|
||||
];
|
||||
const harness = await createHarness({ extensionFactories });
|
||||
|
||||
try {
|
||||
harness.session.setActiveToolsByName(["load_more_tools"]);
|
||||
|
||||
const addedToolNames: string[][] = [];
|
||||
harness.setResponses([
|
||||
() => fauxAssistantMessage(fauxToolCall("load_more_tools", {}), { stopReason: "toolUse" }),
|
||||
(context) => {
|
||||
addedToolNames.push(
|
||||
context.messages
|
||||
.filter((message) => message.role === "toolResult")
|
||||
.flatMap((message) => message.addedToolNames ?? []),
|
||||
);
|
||||
return fauxAssistantMessage("done");
|
||||
},
|
||||
]);
|
||||
|
||||
await harness.session.prompt("start");
|
||||
|
||||
expect(harness.session.getActiveToolNames()).toEqual(["load_more_tools", "after_load"]);
|
||||
expect(addedToolNames).toEqual([["after_load"]]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves before_agent_start system prompt overrides when tools change mid-run", async () => {
|
||||
const extensionFactories: ExtensionFactory[] = [
|
||||
(pi) => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { assistantMsg, userMsg } from "../../utilities.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
describe("issue #6324 branch summary ambient auth", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) {
|
||||
harnesses.pop()?.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("summarizes tree branches when request auth has no API key", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
|
||||
let streamCallCount = 0;
|
||||
harness.session.agent.streamFn = (model, _context, options) => {
|
||||
streamCallCount++;
|
||||
expect(options?.apiKey).toBeUndefined();
|
||||
|
||||
const stream = createAssistantMessageEventStream();
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "branch summary text" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const targetId = harness.sessionManager.appendMessage(userMsg("first branch"));
|
||||
harness.sessionManager.appendMessage(assistantMsg("first reply"));
|
||||
harness.sessionManager.appendMessage(userMsg("abandoned branch work"));
|
||||
harness.sessionManager.appendMessage(assistantMsg("abandoned reply"));
|
||||
|
||||
const result = await harness.session.navigateTree(targetId, { summarize: true });
|
||||
|
||||
expect(result.cancelled).toBe(false);
|
||||
expect(streamCallCount).toBe(1);
|
||||
expect(result.summaryEntry?.type).toBe("branch_summary");
|
||||
expect(result.summaryEntry?.summary).toContain("branch summary text");
|
||||
});
|
||||
});
|
||||
@@ -264,6 +264,7 @@ describe("TreeSelectorComponent", () => {
|
||||
const plainLines = selector.render(30).map(stripVTControlCharacters);
|
||||
const plain = plainLines.join("\n");
|
||||
expect(plain).toContain("branch");
|
||||
expect(plain).toContain("copy");
|
||||
expect(plain).toContain("filters");
|
||||
expect(plain).toContain("cycle");
|
||||
expect(plain).toContain("label time");
|
||||
@@ -272,6 +273,28 @@ describe("TreeSelectorComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy", () => {
|
||||
test("copies the full selected message with ctrl+x", () => {
|
||||
const message = `${"long message ".repeat(30)}\nsecond line`;
|
||||
const tree = buildTree([userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", message)]);
|
||||
const selector = new TreeSelectorComponent(
|
||||
tree,
|
||||
"asst-1",
|
||||
24,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
let copied: string | undefined;
|
||||
selector.onCopy = (text) => {
|
||||
copied = text;
|
||||
};
|
||||
|
||||
selector.handleInput("\x18");
|
||||
|
||||
expect(copied).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
describe("label timestamps", () => {
|
||||
test("toggles label timestamps for labeled nodes", () => {
|
||||
const entries = [userMessage("user-1", null, "hello"), assistantMessage("asst-1", "user-1", "hi")];
|
||||
|
||||
Reference in New Issue
Block a user