new_pull
This commit is contained in:
@@ -2,14 +2,73 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.82.1] - 2026-07-25
|
||||
|
||||
### New Features
|
||||
|
||||
- **Claude Opus 5** — Available on Anthropic and Amazon Bedrock with adaptive thinking (including `xhigh`), inference profiles, and prompt caching. See [Providers](docs/providers.md#api-keys).
|
||||
- **Anthropic gateway bearer auth** — `ANTHROPIC_AUTH_TOKEN` authenticates against Anthropic-compatible gateways that require `Authorization: Bearer`, including compaction and branch summaries. See [Environment Variables or Auth File](docs/providers.md#environment-variables-or-auth-file).
|
||||
- **Faster, more resilient model catalogs** — pi.dev catalogs revalidate with `If-None-Match` so unchanged providers answer with an empty `304`, and llama.cpp models stay listed across restarts. See [llama.cpp](docs/llama-cpp.md).
|
||||
|
||||
### Added
|
||||
|
||||
- Exposed `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` to commands run by built-in and factory-created bash tools.
|
||||
- Exposed the `outputPad` setting to custom message renderers. See [Extensions](docs/extensions.md) ([#7045](https://github.com/earendil-works/pi/pull/7045) by [@xl0](https://github.com/xl0)).
|
||||
- Added inherited `ANTHROPIC_AUTH_TOKEN` bearer authentication for Anthropic-compatible gateways. See [Providers](docs/providers.md#environment-variables-or-auth-file) ([#5871](https://github.com/earendil-works/pi/issues/5871)).
|
||||
- Added inherited Claude Opus 5 support for Anthropic and Amazon Bedrock with adaptive thinking, inference profiles, prompt caching, and preserved AWS validation messages ([#7081](https://github.com/earendil-works/pi/pull/7081) by [@unexge](https://github.com/unexge), [#7083](https://github.com/earendil-works/pi/pull/7083) by [@davidbrai](https://github.com/davidbrai)).
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed pi.dev model catalog refreshes to revalidate with `If-None-Match`, so unchanged provider catalogs answer with an empty `304` instead of a full download.
|
||||
- Changed inherited Radius OAuth device authorization, token exchange, and refresh requests to use the configured gateway directly.
|
||||
- Changed inherited model loading errors to append the underlying cause, so auth failures such as `OAuth refresh failed for openai-codex` report the provider response instead of a bare wrapper message.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported.
|
||||
- Fixed compaction and branch summaries for providers whose authentication resolves entirely to request headers ([#5871](https://github.com/earendil-works/pi/issues/5871))
|
||||
- Fixed unavailable scoped models being hidden from `/models`, allowing them to be removed without editing settings manually ([#6949](https://github.com/earendil-works/pi/issues/6949), [#7032](https://github.com/earendil-works/pi/pull/7032) by [@christianklotz](https://github.com/christianklotz)).
|
||||
- Fixed startup context file discovery to skip directories that match context file names such as `AGENTS.md`, which produced `EISDIR` warnings ([#7106](https://github.com/earendil-works/pi/pull/7106) by [@mrexodia](https://github.com/mrexodia)).
|
||||
- Fixed the llama.cpp extension to persist its model catalog, so llama.cpp models stay listed before the first successful refresh. See [llama.cpp](docs/llama-cpp.md) ([#7072](https://github.com/earendil-works/pi/pull/7072) by [@davidbrai](https://github.com/davidbrai)).
|
||||
|
||||
## [0.82.0] - 2026-07-24
|
||||
|
||||
### New Features
|
||||
|
||||
- **Constrained tool sampling** — Tools can prefer or require strict JSON Schema sampling or use OpenAI Lark/regex grammars, with model capability metadata preventing unsupported requests. See [Constrained Sampling for Tools](../ai/README.md#constrained-sampling-for-tools).
|
||||
- **OpenRouter and Kimi Code sign-in** — Use `/login` to authorize OpenRouter or a Kimi Code subscription without manually configuring API keys. See [OpenRouter](docs/providers.md#openrouter).
|
||||
- **Session-aware, streaming bash integrations** — Bash tools receive current session/model metadata, while direct RPC bash commands stream correlated output. See [Bash Tool Session Environment](docs/environment-variables.md#bash-tool-session-environment) and [RPC bash events](docs/rpc.md#bash_execution_update).
|
||||
|
||||
### Added
|
||||
|
||||
- Added inherited `Tool.constrainedSampling` with strict JSON Schema (`prefer`/`require`) and OpenAI Lark/regex grammar variants across OpenAI, Anthropic, Amazon Bedrock, Google Gemini, and Mistral. See [Constrained Sampling for Tools](../ai/README.md#constrained-sampling-for-tools).
|
||||
- Added inherited `supportsGrammarTools` and `supportsStrictTools` compatibility flags, expanded `supportsStrictMode` coverage, and generated model capability metadata to gate constrained sampling.
|
||||
- Added inherited Kimi Code subscription OAuth login for the Kimi For Coding provider, including device authorization and automatic token refresh ([#6935](https://github.com/earendil-works/pi/pull/6935) by [@zaycruz](https://github.com/zaycruz)).
|
||||
- Added inherited OpenRouter OAuth PKCE login through `/login`, minting a user-controlled API key. See [OpenRouter](docs/providers.md#openrouter) ([#6927](https://github.com/earendil-works/pi/pull/6927) by [@rsaryev](https://github.com/rsaryev)).
|
||||
- Exposed `PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL` to commands run by built-in and factory-created bash tools. See [Bash Tool Session Environment](docs/environment-variables.md#bash-tool-session-environment).
|
||||
- Added streaming `bash_execution_update` events for direct RPC bash commands, correlated with request IDs. See [RPC bash events](docs/rpc.md#bash_execution_update) ([#6971](https://github.com/earendil-works/pi/pull/6971) by [@ananthakumaran](https://github.com/ananthakumaran)).
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed inherited generated model catalogs to expose only provider-verified reasoning effort levels from models.dev ([#6928](https://github.com/earendil-works/pi/pull/6928) by [@davidbrai](https://github.com/davidbrai)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed inherited DNS lookup failures such as `getaddrinfo`, `ENOTFOUND`, and `EAI_AGAIN` to trigger automatic assistant retries ([#6946](https://github.com/earendil-works/pi/pull/6946) by [@christianklotz](https://github.com/christianklotz)).
|
||||
- Fixed inherited OpenRouter Anthropic cache breakpoints to advance through tool results and enabled cache control for `~anthropic/*-latest` aliases ([#6941](https://github.com/earendil-works/pi/pull/6941) by [@mteam88](https://github.com/mteam88)).
|
||||
- Fixed inherited OpenAI Codex WebSocket sessions to retry once without a missing previous-response continuation after `previous_response_not_found` errors ([#6955](https://github.com/earendil-works/pi/pull/6955) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Fixed TUI debug and crash logs to respect custom agent directories instead of always writing under `~/.pi/agent` ([#6958](https://github.com/earendil-works/pi/pull/6958) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Fixed slow Ctrl+G external-editor startup when the system temporary directory contains many entries ([#6903](https://github.com/earendil-works/pi/pull/6903) by [@christianklotz](https://github.com/christianklotz)).
|
||||
- Fixed startup resource display to preserve relative paths for sibling npm extensions loaded by a package ([#6964](https://github.com/earendil-works/pi/pull/6964) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Fixed compaction and branch-summary requests to use fresh routing session IDs with prompt caching disabled where supported ([#6618](https://github.com/earendil-works/pi/pull/6618) by [@tmustier](https://github.com/tmustier)).
|
||||
- Fixed explicit self-updates when `PI_SKIP_VERSION_CHECK` is set ([#6977](https://github.com/earendil-works/pi/issues/6977)).
|
||||
- Fixed scoped model IDs containing brackets to resolve as literal exact matches before glob matching ([#6210](https://github.com/earendil-works/pi/issues/6210)).
|
||||
- Fixed inherited OpenAI and Anthropic provider retry waits to honor abort signals and configured delay limits ([#6980](https://github.com/earendil-works/pi/pull/6980) by [@petrroll](https://github.com/petrroll)).
|
||||
- Fixed fresh installs from preferring bundled model catalogs over newer remote catalogs because package file mtimes were newer ([#7016](https://github.com/earendil-works/pi/pull/7016) by [@davidbrai](https://github.com/davidbrai)).
|
||||
- Fixed inherited editor scroll indicators overflowing narrow terminals ([#7015](https://github.com/earendil-works/pi/pull/7015) by [@christianklotz](https://github.com/christianklotz)).
|
||||
- Fixed llama.cpp models to use the loaded context window as their output token limit instead of capping it at 16K ([#7034](https://github.com/earendil-works/pi/pull/7034) by [@christianklotz](https://github.com/christianklotz)).
|
||||
- Fixed release source archives to include the generated provider model data used to build standalone binaries.
|
||||
- Updated the packaged `protobufjs` dependency to 7.6.5 to address GHSA-j3f2-48v5-ccww ([#7005](https://github.com/earendil-works/pi/issues/7005)).
|
||||
- Fixed `/copy` on Wayland to fall back to X11 or OSC 52 when `wl-copy` fails ([#7009](https://github.com/earendil-works/pi/pull/7009) by [@rkfshakti](https://github.com/rkfshakti)).
|
||||
- Fixed `/model` to reload updated `models.json` configuration when opening the model picker ([#6999](https://github.com/earendil-works/pi/issues/6999)).
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
|
||||
@@ -66,7 +125,6 @@
|
||||
- Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations.
|
||||
- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes.
|
||||
- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)).
|
||||
- Fixed slow Ctrl+G external-editor startup when the system temporary directory contains many entries ([#6774](https://github.com/earendil-works/pi/issues/6774)).
|
||||
- Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)).
|
||||
- Fixed obsolete custom UI, custom tool, and custom editor examples in the extension documentation ([#6735](https://github.com/earendil-works/pi/issues/6735)).
|
||||
- Fixed Kimi Coding sessions to show API-equivalent implied costs with the subscription indicator.
|
||||
|
||||
@@ -20,7 +20,7 @@ Pi has two summarization mechanisms:
|
||||
| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
|
||||
| Branch summarization | `/tree` navigation | Preserve context when switching branches |
|
||||
|
||||
Both use the same structured summary format and track file operations cumulatively.
|
||||
Both use the same structured summary format and track file operations cumulatively. Compaction and branch-summary requests use fresh routing session IDs and, where supported by the provider, disable prompt-cache writes because these one-off prompts are unlikely to be reused.
|
||||
|
||||
## Compaction
|
||||
|
||||
|
||||
@@ -737,6 +737,8 @@ interface ProviderModelConfig {
|
||||
supportsDeveloperRole?: boolean;
|
||||
supportsReasoningEffort?: boolean;
|
||||
supportsUsageInStreaming?: boolean;
|
||||
supportsStrictMode?: boolean;
|
||||
supportsOpenAIGrammarTools?: boolean; // openai-completions/openai-responses; false falls back to normal function tools
|
||||
maxTokensField?: "max_completion_tokens" | "max_tokens";
|
||||
requiresToolResultName?: boolean;
|
||||
requiresAssistantAfterToolResult?: boolean;
|
||||
@@ -755,6 +757,7 @@ interface ProviderModelConfig {
|
||||
supportsCacheControlOnTools?: boolean;
|
||||
forceAdaptiveThinking?: boolean;
|
||||
allowEmptySignature?: boolean;
|
||||
supportsStrictTools?: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2791,7 +2791,7 @@ Register a custom renderer for messages with your `customType`. Use message rend
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
|
||||
pi.registerMessageRenderer("my-extension", (message, options, theme) => {
|
||||
const { expanded } = options;
|
||||
const { expanded, outputPad } = options;
|
||||
let text = theme.fg("accent", `[${message.customType}] `);
|
||||
text += message.content;
|
||||
|
||||
@@ -2799,7 +2799,7 @@ pi.registerMessageRenderer("my-extension", (message, options, theme) => {
|
||||
text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2));
|
||||
}
|
||||
|
||||
return new Text(text, 0, 0);
|
||||
return new Text(text, outputPad, 0);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -375,6 +375,8 @@ Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plu
|
||||
|
||||
Some Anthropic-compatible providers emit thinking blocks with empty signatures and still expect them on replay. Set `allowEmptySignature` to `true` only for those providers; real Anthropic rejects empty thinking signatures.
|
||||
|
||||
Built-in Anthropic models enable `supportsStrictTools` in their model metadata. Custom Anthropic-compatible models must set it to `true` when their endpoint accepts strict JSON-schema tool definitions.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
@@ -408,6 +410,7 @@ Some Anthropic-compatible providers emit thinking blocks with empty signatures a
|
||||
| `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. |
|
||||
| `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. |
|
||||
| `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: `false`. |
|
||||
| `supportsStrictTools` | Whether the provider accepts strict JSON-schema tool definitions. Default: `false`; built-in Anthropic models enable it in generated metadata. |
|
||||
|
||||
## OpenAI Compatibility
|
||||
|
||||
@@ -448,7 +451,8 @@ For providers with partial OpenAI compatibility, use the `compat` field.
|
||||
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result 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 |
|
||||
| `supportsStrictMode` | Whether the provider accepts strict JSON-schema function tool definitions. Defaults depend on the API; built-in OpenAI models carry explicit capability metadata. |
|
||||
| `supportsOpenAIGrammarTools` | Whether OpenAI-compatible APIs emit custom Lark/regex grammar tools. When `false`, grammar-constrained tools fall back to normal function tools. Default: `false`; the built-in model catalog enables it for GPT-5+ models on OpenAI, OpenAI Codex, Azure OpenAI, GitHub Copilot, opencode, and Cloudflare AI Gateway. |
|
||||
| `deferredToolsMode` | Use provider-specific deferred tool serialization. Currently only `"kimi"` is supported for Kimi's OpenAI-compatible Chat Completions format. |
|
||||
| `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). |
|
||||
|
||||
@@ -23,7 +23,7 @@ Common options:
|
||||
- **Responses**: JSON objects with `type: "response"` indicating command success/failure
|
||||
- **Events**: Agent events streamed to stdout as JSON lines
|
||||
|
||||
All commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`.
|
||||
All commands support an optional `id` field for request/response correlation. If provided, the corresponding response will include the same `id`. `bash_execution_update` events also include the `id` of their originating `bash` command.
|
||||
|
||||
### Framing
|
||||
|
||||
@@ -455,15 +455,18 @@ Response:
|
||||
|
||||
#### bash
|
||||
|
||||
Execute a shell command and add output to conversation context.
|
||||
Execute a shell command and add output to conversation context. Output streams as `bash_execution_update` events while the command runs; the response contains the final result.
|
||||
|
||||
```json
|
||||
{"type": "bash", "command": "ls -la"}
|
||||
{"id": "req-1", "type": "bash", "command": "ls -la"}
|
||||
```
|
||||
|
||||
Include an `id` to associate streamed `bash_execution_update` events with this command.
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "req-1",
|
||||
"type": "response",
|
||||
"command": "bash",
|
||||
"success": true,
|
||||
@@ -494,7 +497,7 @@ If output was truncated, includes `fullOutputPath`:
|
||||
|
||||
**How bash results reach the LLM:**
|
||||
|
||||
The `bash` command executes immediately and returns a `BashResult`. Internally, a `BashExecutionMessage` is created and stored in the agent's message state. This message does NOT emit an event.
|
||||
The `bash` command executes immediately and returns a `BashResult`. Internally, a `BashExecutionMessage` is created and stored in the agent's message state.
|
||||
|
||||
When the next `prompt` command is sent, all messages (including `BashExecutionMessage`) are transformed before being sent to the LLM. The `BashExecutionMessage` is converted to a `UserMessage` with this format:
|
||||
|
||||
@@ -509,7 +512,6 @@ drwxr-xr-x ...
|
||||
This means:
|
||||
1. Bash output is included in the LLM context on the **next prompt**, not immediately
|
||||
2. Multiple bash commands can be executed before a prompt; all outputs will be included
|
||||
3. No event is emitted for the `BashExecutionMessage` itself
|
||||
|
||||
#### abort_bash
|
||||
|
||||
@@ -829,7 +831,7 @@ Each command has:
|
||||
|
||||
## Events
|
||||
|
||||
Events are streamed to stdout as JSON lines during agent operation. Events do NOT include an `id` field (only responses do).
|
||||
Events are streamed to stdout as JSON lines during agent operation. Events do not generally include an `id` field; `bash_execution_update` includes the `id` of its originating `bash` command when one was provided.
|
||||
|
||||
### Event Types
|
||||
|
||||
@@ -843,6 +845,7 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
|
||||
| `message_start` | Message begins |
|
||||
| `message_update` | Streaming update (text/thinking/toolcall deltas) |
|
||||
| `message_end` | Message completes |
|
||||
| `bash_execution_update` | Direct RPC bash command output chunk |
|
||||
| `tool_execution_start` | Tool begins execution |
|
||||
| `tool_execution_update` | Tool execution progress (streaming output) |
|
||||
| `tool_execution_end` | Tool completes |
|
||||
@@ -951,6 +954,20 @@ Example streaming a text response:
|
||||
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world","partial":{...}}}
|
||||
```
|
||||
|
||||
### bash_execution_update
|
||||
|
||||
Emitted once for each output chunk from a direct `bash` command. `id` matches the command's `id`, allowing clients to associate output with the correct command.
|
||||
|
||||
Events stream all output while the command runs, even if the final `bash` response's `output` is truncated.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "bash_execution_update",
|
||||
"id": "req-1",
|
||||
"delta": "total 48\n"
|
||||
}
|
||||
```
|
||||
|
||||
### tool_execution_start / tool_execution_update / tool_execution_end
|
||||
|
||||
Emitted when a tool begins, streams progress, and completes execution.
|
||||
|
||||
@@ -142,7 +142,7 @@ Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--off
|
||||
| `retry.provider.maxRetries` | number | `0` | Provider/SDK retry attempts |
|
||||
| `retry.provider.maxRetryDelayMs` | number | `60000` | Max server-requested delay before failing (60s) |
|
||||
|
||||
When a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs` (e.g., Google's "quota will reset after 5h"), the request fails immediately with an informative error instead of waiting silently. Set to `0` to disable the cap.
|
||||
When a provider requests a retry delay longer than `retry.provider.maxRetryDelayMs`, the request fails immediately with an informative error instead of waiting silently. Set it to `0` to disable the limit.
|
||||
|
||||
Keep `retry.provider.maxRetries` at `0` unless provider-level retries are explicitly needed. Setting it above `0` can make SDK/provider retries handle out-of-usage-limit errors before Pi sees them, which may block the agent until the provider quota resets in some circumstances.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* pi --extension examples/extensions/custom-compaction.ts
|
||||
*/
|
||||
|
||||
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||
import { complete } from "@earendil-works/pi-ai/compat";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
|
||||
@@ -96,6 +97,8 @@ ${conversationText}
|
||||
env: auth.env,
|
||||
maxTokens: 8192,
|
||||
signal,
|
||||
cacheRetention: "none",
|
||||
sessionId: uuidv7(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"private": true,
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||
"private": true,
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"private": true,
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||
import { complete, type Message } from "@earendil-works/pi-ai/compat";
|
||||
import type { ExtensionAPI, SessionEntry } from "@earendil-works/pi-coding-agent";
|
||||
import { BorderedLoader, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
|
||||
@@ -136,7 +137,14 @@ export default function (pi: ExtensionAPI) {
|
||||
const response = await complete(
|
||||
ctx.model!,
|
||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
||||
{ apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal: loader.signal },
|
||||
{
|
||||
apiKey: auth.apiKey,
|
||||
headers: auth.headers,
|
||||
env: auth.env,
|
||||
signal: loader.signal,
|
||||
cacheRetention: "none",
|
||||
sessionId: uuidv7(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.stopReason === "aborted") {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// Register custom renderer for "status-update" messages
|
||||
pi.registerMessageRenderer("status-update", (message, { expanded }, theme) => {
|
||||
pi.registerMessageRenderer("status-update", (message, { expanded, outputPad }, theme) => {
|
||||
const details = message.details as { level: string; timestamp: number } | undefined;
|
||||
const level = details?.level ?? "info";
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
// Use Box with customMessageBg for consistent styling
|
||||
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
||||
const box = new Box(outputPad, 1, (t) => theme.bg("customMessageBg", t));
|
||||
box.addChild(new Text(text, 0, 0));
|
||||
return box;
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.11.1",
|
||||
"version": "1.12.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.11.1",
|
||||
"version": "1.12.1",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"private": true,
|
||||
"version": "1.11.1",
|
||||
"version": "1.12.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { uuidv7 } from "@earendil-works/pi-ai";
|
||||
import { complete, getModel } from "@earendil-works/pi-ai/compat";
|
||||
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
||||
import { DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
||||
@@ -193,6 +194,8 @@ export default function (pi: ExtensionAPI) {
|
||||
headers: auth.headers,
|
||||
env: auth.env,
|
||||
reasoningEffort: "high",
|
||||
cacheRetention: "none",
|
||||
sessionId: uuidv7(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
+18
-18
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.81.1"
|
||||
"@earendil-works/pi-coding-agent": "0.82.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -450,11 +450,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
@@ -465,8 +465,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
@@ -489,13 +489,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent": {
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-tui": "^0.81.1",
|
||||
"@earendil-works/pi-agent-core": "^0.82.1",
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"@earendil-works/pi-tui": "^0.82.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -523,8 +523,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
@@ -1603,9 +1603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
|
||||
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"private": true,
|
||||
"description": "Lockfile root used by the Pi installer and updater.",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.81.1"
|
||||
"@earendil-works/pi-coding-agent": "0.82.1"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "7.6.5",
|
||||
"rimraf": "6.1.2",
|
||||
"gaxios": {
|
||||
"rimraf": "6.1.2"
|
||||
|
||||
+15
-15
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-tui": "^0.81.1",
|
||||
"@earendil-works/pi-agent-core": "^0.82.1",
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"@earendil-works/pi-tui": "^0.82.1",
|
||||
"@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.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
@@ -489,8 +489,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
@@ -513,8 +513,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
@@ -1593,9 +1593,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
|
||||
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.81.1",
|
||||
"version": "0.82.1",
|
||||
"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.81.1",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-tui": "^0.81.1",
|
||||
"@earendil-works/pi-agent-core": "^0.82.1",
|
||||
"@earendil-works/pi-ai": "^0.82.1",
|
||||
"@earendil-works/pi-tui": "^0.82.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -59,6 +59,7 @@
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"overrides": {
|
||||
"protobufjs": "7.6.5",
|
||||
"rimraf": "6.1.2",
|
||||
"gaxios": {
|
||||
"rimraf": "6.1.2"
|
||||
|
||||
@@ -333,6 +333,7 @@ ${chalk.bold("Examples:")}
|
||||
${APP_NAME} --export session.jsonl output.html
|
||||
|
||||
${chalk.bold("Environment Variables:")}
|
||||
ANTHROPIC_AUTH_TOKEN - Anthropic bearer auth token
|
||||
ANTHROPIC_API_KEY - Anthropic Claude API key
|
||||
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
|
||||
ANT_LING_API_KEY - Ant Ling API key
|
||||
|
||||
@@ -176,7 +176,9 @@ export type AgentSessionEvent =
|
||||
source: "compaction";
|
||||
reason: "manual" | "threshold" | "overflow";
|
||||
}
|
||||
| { type: "summarization_retry_finished" };
|
||||
| { type: "summarization_retry_finished" }
|
||||
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
||||
| { type: "bash_execution_update"; id?: string; delta: string };
|
||||
|
||||
/** Listener function for agent session events */
|
||||
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
||||
@@ -403,7 +405,7 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
private async _getRequiredRequestAuth(model: Model<any>): Promise<{
|
||||
apiKey: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
}> {
|
||||
@@ -417,7 +419,7 @@ export class AgentSession {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (result?.auth.apiKey) {
|
||||
if (result && (result.auth.apiKey || result.auth.headers)) {
|
||||
return {
|
||||
apiKey: result.auth.apiKey,
|
||||
headers: withoutDeletedHeaders(result.auth.headers),
|
||||
@@ -2055,11 +2057,7 @@ export class AgentSession {
|
||||
let headers: Record<string, string> | undefined;
|
||||
let env: Record<string, string> | undefined;
|
||||
if (this.agent.streamFunction === streamSimple) {
|
||||
const authResult = await this._modelRuntime.getAuth(this.model);
|
||||
if (!authResult?.auth.apiKey) return false;
|
||||
apiKey = authResult.auth.apiKey;
|
||||
headers = withoutDeletedHeaders(authResult.auth.headers);
|
||||
env = authResult.env;
|
||||
({ apiKey, headers, env } = await this._getRequiredRequestAuth(this.model));
|
||||
} else {
|
||||
({ apiKey, headers, env } = await this._getSummarizationRequestAuth(this.model));
|
||||
}
|
||||
@@ -2760,12 +2758,13 @@ export class AgentSession {
|
||||
* @param command The bash command to execute
|
||||
* @param onChunk Optional streaming callback for output
|
||||
* @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
|
||||
* @param options.id Optional identifier included in bash execution update events
|
||||
* @param options.operations Custom BashOperations for remote execution
|
||||
*/
|
||||
async executeBash(
|
||||
command: string,
|
||||
onChunk?: (chunk: string) => void,
|
||||
options?: { excludeFromContext?: boolean; operations?: BashOperations },
|
||||
options?: { excludeFromContext?: boolean; id?: string; operations?: BashOperations },
|
||||
): Promise<BashResult> {
|
||||
this._bashAbortController = new AbortController();
|
||||
|
||||
@@ -2780,7 +2779,10 @@ export class AgentSession {
|
||||
this.sessionManager.getCwd(),
|
||||
options?.operations ?? createLocalBashOperations({ shellPath }),
|
||||
{
|
||||
onChunk,
|
||||
onChunk: (delta) => {
|
||||
onChunk?.(delta);
|
||||
this._emit({ type: "bash_execution_update", id: options?.id, delta });
|
||||
},
|
||||
signal: this._bashAbortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
Api,
|
||||
AssistantMessageEvent,
|
||||
AssistantMessageEventStream,
|
||||
ConstrainedSamplingConfig,
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
@@ -452,6 +453,8 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
||||
promptGuidelines?: string[];
|
||||
/** Parameter schema (TypeBox) */
|
||||
parameters: TParams;
|
||||
/** Optional provider-side constrained sampling request for this tool. Set false to explicitly disable it, equivalent to leaving it undefined. */
|
||||
constrainedSampling?: false | ConstrainedSamplingConfig;
|
||||
/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */
|
||||
renderShell?: "default" | "self";
|
||||
|
||||
@@ -1126,6 +1129,8 @@ export interface SessionBeforeTreeResult {
|
||||
|
||||
export interface MessageRenderOptions {
|
||||
expanded: boolean;
|
||||
/** Horizontal padding configured by the outputPad setting. */
|
||||
outputPad: number;
|
||||
}
|
||||
|
||||
export interface EntryRenderOptions {
|
||||
|
||||
@@ -97,6 +97,7 @@ const OpenAICompletionsCompatSchema = Type.Object({
|
||||
cacheControlFormat: Type.Optional(Type.Literal("anthropic")),
|
||||
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
||||
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
||||
supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()),
|
||||
supportsStrictMode: Type.Optional(Type.Boolean()),
|
||||
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
|
||||
deferredToolsMode: Type.Optional(Type.Literal("kimi")),
|
||||
@@ -112,6 +113,8 @@ const OpenAIResponsesCompatSchema = Type.Object({
|
||||
Type.Union([Type.Literal("openai"), Type.Literal("openai-nosession"), Type.Literal("openrouter")]),
|
||||
),
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
supportsStrictMode: Type.Optional(Type.Boolean()),
|
||||
supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()),
|
||||
supportsToolSearch: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
@@ -120,7 +123,10 @@ const AnthropicMessagesCompatSchema = Type.Object({
|
||||
supportsLongCacheRetention: Type.Optional(Type.Boolean()),
|
||||
sendSessionAffinityHeaders: Type.Optional(Type.Boolean()),
|
||||
supportsCacheControlOnTools: Type.Optional(Type.Boolean()),
|
||||
supportsTemperature: Type.Optional(Type.Boolean()),
|
||||
forceAdaptiveThinking: Type.Optional(Type.Boolean()),
|
||||
allowEmptySignature: Type.Optional(Type.Boolean()),
|
||||
supportsStrictTools: Type.Optional(Type.Boolean()),
|
||||
supportsToolReferences: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ export class ModelRegistry {
|
||||
}
|
||||
|
||||
/** Reload models.json asynchronously. Await before making synchronous registry reads. */
|
||||
refresh(): Promise<void> {
|
||||
return this.runtime.reloadConfig();
|
||||
async refresh(): Promise<void> {
|
||||
await this.runtime.refresh();
|
||||
}
|
||||
|
||||
getError(): string | undefined {
|
||||
|
||||
@@ -260,6 +260,7 @@ export function parseModelPattern(
|
||||
*/
|
||||
export interface ModelScopeDiagnostic {
|
||||
type: "warning";
|
||||
code: "no-match" | "invalid-thinking-level";
|
||||
message: string;
|
||||
pattern: string;
|
||||
}
|
||||
@@ -293,6 +294,14 @@ export async function resolveModelScopeWithDiagnostics(
|
||||
}
|
||||
}
|
||||
|
||||
const exactMatch = findExactModelReferenceMatch(globPattern, availableModels);
|
||||
if (exactMatch) {
|
||||
if (!scopedModels.find((sm) => modelsAreEqual(sm.model, exactMatch))) {
|
||||
scopedModels.push({ model: exactMatch, thinkingLevel });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match against "provider/modelId" format OR just model ID
|
||||
// This allows "*sonnet*" to match without requiring "anthropic/*sonnet*"
|
||||
const matchingModels = availableModels.filter((m) => {
|
||||
@@ -301,7 +310,12 @@ export async function resolveModelScopeWithDiagnostics(
|
||||
});
|
||||
|
||||
if (matchingModels.length === 0) {
|
||||
diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "no-match",
|
||||
message: `No models match pattern "${pattern}"`,
|
||||
pattern,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -316,11 +330,16 @@ export async function resolveModelScopeWithDiagnostics(
|
||||
const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels);
|
||||
|
||||
if (warning) {
|
||||
diagnostics.push({ type: "warning", message: warning, pattern });
|
||||
diagnostics.push({ type: "warning", code: "invalid-thinking-level", message: warning, pattern });
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
diagnostics.push({ type: "warning", message: `No models match pattern "${pattern}"`, pattern });
|
||||
diagnostics.push({
|
||||
type: "warning",
|
||||
code: "no-match",
|
||||
message: `No models match pattern "${pattern}"`,
|
||||
pattern,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -140,18 +140,13 @@ export class ModelRuntime implements Models {
|
||||
(modelsPath
|
||||
? new FileModelsStore(options.modelsStorePath ?? join(dirname(modelsPath), "models-store.json"))
|
||||
: new InMemoryCodingAgentModelsStore());
|
||||
const builtinModelDataGeneratedAt = builtinProviderCatalog.getBuiltinModelDataGeneratedAt();
|
||||
const providers = builtinProviderCatalog
|
||||
.builtinProviders()
|
||||
.map((provider) =>
|
||||
provider.id === "radius"
|
||||
? provider
|
||||
: withRemoteCatalog(
|
||||
provider,
|
||||
options.catalogBaseUrl,
|
||||
builtinProviderCatalog.getBuiltinModelDataUrl(
|
||||
provider.id as builtinProviderCatalog.BuiltinProvider,
|
||||
),
|
||||
),
|
||||
: withRemoteCatalog(provider, options.catalogBaseUrl, builtinModelDataGeneratedAt),
|
||||
);
|
||||
const runtime = new ModelRuntime(
|
||||
credentials,
|
||||
@@ -518,14 +513,10 @@ export class ModelRuntime implements Models {
|
||||
await this.refresh({ allowNetwork: this.modelNetworkEnabled });
|
||||
}
|
||||
|
||||
async reloadConfig(): Promise<void> {
|
||||
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
|
||||
this.config = await ModelConfig.load(this.modelsPath);
|
||||
this.configureRadiusProviders();
|
||||
this.rebuildProviders();
|
||||
await this.refresh({ allowNetwork: this.modelNetworkEnabled });
|
||||
}
|
||||
|
||||
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
|
||||
const refreshOptions = {
|
||||
...options,
|
||||
allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { Api, Model, ModelsStoreEntry, Provider } from "@earendil-works/pi-ai";
|
||||
import { VERSION } from "../config.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
@@ -32,13 +31,10 @@ function parseCatalog(providerId: string, value: unknown): Model<Api>[] {
|
||||
|
||||
function remoteModels(
|
||||
entry: ModelsStoreEntry | undefined,
|
||||
localLastModified: number | undefined,
|
||||
localGeneratedAt: number | undefined,
|
||||
): readonly Model<Api>[] {
|
||||
if (!entry) return [];
|
||||
if (
|
||||
localLastModified !== undefined &&
|
||||
(entry.lastModified === undefined || entry.lastModified <= localLastModified)
|
||||
) {
|
||||
if (localGeneratedAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= localGeneratedAt)) {
|
||||
return [];
|
||||
}
|
||||
return entry.models;
|
||||
@@ -48,7 +44,7 @@ function remoteModels(
|
||||
export function withRemoteCatalog(
|
||||
provider: Provider,
|
||||
catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL,
|
||||
localCatalogUrl?: URL,
|
||||
localGeneratedAt?: number,
|
||||
): Provider {
|
||||
let dynamicModels: readonly Model<Api>[] = [];
|
||||
let inflightRefresh: Promise<void> | undefined;
|
||||
@@ -59,16 +55,8 @@ export function withRemoteCatalog(
|
||||
refreshModels: (context) => {
|
||||
inflightRefresh ??= (async () => {
|
||||
try {
|
||||
const localLastModified = localCatalogUrl
|
||||
? await stat(localCatalogUrl).then(
|
||||
(value) => value.mtimeMs,
|
||||
() => undefined,
|
||||
)
|
||||
: undefined;
|
||||
const stored = await context.store.read();
|
||||
dynamicModels = remoteModels(stored, localLastModified).filter(
|
||||
(model) => model.provider === provider.id,
|
||||
);
|
||||
dynamicModels = remoteModels(stored, localGeneratedAt).filter((model) => model.provider === provider.id);
|
||||
if (!context.allowNetwork || context.signal?.aborted) return;
|
||||
if (
|
||||
!context.force &&
|
||||
@@ -79,21 +67,38 @@ export function withRemoteCatalog(
|
||||
return;
|
||||
}
|
||||
|
||||
// Only revalidate when a cached body backs the validator, so a 304 can never
|
||||
// leave the overlay empty.
|
||||
const validator = stored?.models.length ? stored.etag : undefined;
|
||||
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"User-Agent": getPiUserAgent(VERSION),
|
||||
...(validator ? { "if-none-match": validator } : {}),
|
||||
},
|
||||
signal: context.signal,
|
||||
});
|
||||
if (context.signal?.aborted) return;
|
||||
const checkedAt = Date.now();
|
||||
// Unchanged: dynamicModels already holds the stored overlay, so only the
|
||||
// freshness window moves.
|
||||
if (response.status === 304 && stored) {
|
||||
await context.store.write({ ...stored, checkedAt });
|
||||
return;
|
||||
}
|
||||
if (response.status === 404 || response.status === 501) {
|
||||
await context.store.write({ ...(stored ?? { models: [] }), checkedAt, lastModified: 0 });
|
||||
await context.store.write({
|
||||
...(stored ?? { models: [] }),
|
||||
checkedAt,
|
||||
lastModified: 0,
|
||||
etag: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
// Transient failure: the cached body and its validator stay valid, so keep the
|
||||
// etag and let the next refresh revalidate instead of downloading the catalog.
|
||||
await context.store.write({ ...(stored ?? { models: [] }), checkedAt });
|
||||
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
|
||||
}
|
||||
@@ -104,8 +109,9 @@ export function withRemoteCatalog(
|
||||
models: refreshed,
|
||||
checkedAt,
|
||||
lastModified: Number.isNaN(lastModified) ? 0 : lastModified,
|
||||
etag: response.headers.get("etag") ?? undefined,
|
||||
};
|
||||
dynamicModels = remoteModels(entry, localLastModified);
|
||||
dynamicModels = remoteModels(entry, localGeneratedAt);
|
||||
await context.store.write(entry);
|
||||
} finally {
|
||||
inflightRefresh = undefined;
|
||||
|
||||
@@ -70,6 +70,9 @@ function loadContextFileFromDir(dir: string): { path: string; content: string }
|
||||
const filePath = join(dir, filename);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
path: filePath,
|
||||
content: readFileSync(filePath, "utf-8"),
|
||||
|
||||
@@ -11,6 +11,7 @@ export function wrapToolDefinition<TDetails = unknown>(
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
constrainedSampling: definition.constrainedSampling,
|
||||
prepareArguments: definition.prepareArguments,
|
||||
executionMode: definition.executionMode,
|
||||
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionContext) =>
|
||||
@@ -38,6 +39,7 @@ export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDef
|
||||
label: tool.label,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as any,
|
||||
constrainedSampling: tool.constrainedSampling,
|
||||
prepareArguments: tool.prepareArguments,
|
||||
executionMode: tool.executionMode,
|
||||
execute: async (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
|
||||
|
||||
@@ -12,8 +12,6 @@ import { LlamaClient, type LlamaModelInfo, llamaInferenceUrl, normalizeLlamaServ
|
||||
|
||||
export const LLAMA_PROVIDER_ID = "llama.cpp";
|
||||
export const DEFAULT_LLAMA_SERVER_URL = "http://127.0.0.1:8080";
|
||||
const DEFAULT_MAX_TOKENS = 16384;
|
||||
|
||||
function credentialServerUrl(credential: ApiKeyCredential | undefined): string | undefined {
|
||||
const value = credential?.env?.LLAMA_BASE_URL;
|
||||
return typeof value === "string" && value.trim() ? normalizeLlamaServerUrl(value) : undefined;
|
||||
@@ -40,7 +38,7 @@ function toPiModel(model: LlamaModelInfo, serverUrl: string): Model<"openai-comp
|
||||
input: model.architecture?.input_modalities?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow,
|
||||
maxTokens: Math.min(DEFAULT_MAX_TOKENS, contextWindow),
|
||||
maxTokens: contextWindow,
|
||||
compat: {
|
||||
supportsStore: false,
|
||||
supportsDeveloperRole: false,
|
||||
@@ -113,11 +111,20 @@ export function createLlamaProvider(): LlamaProviderController {
|
||||
},
|
||||
getModels: () => models,
|
||||
refreshModels: async (context: RefreshModelsContext): Promise<void> => {
|
||||
const stored = await context.store.read();
|
||||
if (stored) {
|
||||
models = stored.models.filter(
|
||||
(model): model is Model<"openai-completions"> =>
|
||||
model.provider === LLAMA_PROVIDER_ID && model.api === "openai-completions",
|
||||
);
|
||||
}
|
||||
|
||||
if (!context.allowNetwork || context.signal?.aborted || context.credential?.type !== "api_key") return;
|
||||
const serverUrl = credentialServerUrl(context.credential);
|
||||
if (!serverUrl) return;
|
||||
const catalog = await new LlamaClient(serverUrl, context.credential.key).list({ signal: context.signal });
|
||||
setCatalog(catalog, serverUrl);
|
||||
if (!context.signal?.aborted) await context.store.write({ models, checkedAt: Date.now() });
|
||||
},
|
||||
stream: (model, context, options) => stream(model, context, options as ProviderStreamOptions | undefined),
|
||||
streamSimple: (model, context, options) => streamSimple(model, context, options),
|
||||
|
||||
@@ -16,16 +16,19 @@ export class CustomMessageComponent extends Container {
|
||||
private customComponent?: Component;
|
||||
private markdownTheme: MarkdownTheme;
|
||||
private _expanded = false;
|
||||
private outputPad: number;
|
||||
|
||||
constructor(
|
||||
message: CustomMessage<unknown>,
|
||||
customRenderer?: MessageRenderer,
|
||||
markdownTheme: MarkdownTheme = getMarkdownTheme(),
|
||||
outputPad = 1,
|
||||
) {
|
||||
super();
|
||||
this.message = message;
|
||||
this.customRenderer = customRenderer;
|
||||
this.markdownTheme = markdownTheme;
|
||||
this.outputPad = outputPad;
|
||||
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
@@ -42,6 +45,13 @@ export class CustomMessageComponent extends Container {
|
||||
}
|
||||
}
|
||||
|
||||
setOutputPad(outputPad: number): void {
|
||||
if (this.outputPad !== outputPad) {
|
||||
this.outputPad = outputPad;
|
||||
this.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
this.rebuild();
|
||||
@@ -58,7 +68,11 @@ export class CustomMessageComponent extends Container {
|
||||
// Try custom renderer first - it handles its own styling
|
||||
if (this.customRenderer) {
|
||||
try {
|
||||
const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
|
||||
const component = this.customRenderer(
|
||||
this.message,
|
||||
{ expanded: this._expanded, outputPad: this.outputPad },
|
||||
theme,
|
||||
);
|
||||
if (component) {
|
||||
// Custom renderer provides its own styled component
|
||||
this.customComponent = component;
|
||||
|
||||
@@ -36,7 +36,7 @@ function enableAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[
|
||||
for (const id of targets) {
|
||||
if (!result.includes(id)) result.push(id);
|
||||
}
|
||||
return result.length === allIds.length ? null : result;
|
||||
return result.length === allIds.length && result.every((id) => allIds.includes(id)) ? null : result;
|
||||
}
|
||||
|
||||
function clearAll(enabledIds: EnabledIds, allIds: string[], targetIds?: string[]): EnabledIds {
|
||||
@@ -67,7 +67,7 @@ function getSortedIds(enabledIds: EnabledIds, allIds: string[]): string[] {
|
||||
|
||||
interface ModelItem {
|
||||
fullId: string;
|
||||
model: Model<any>;
|
||||
model: Model<any> | undefined;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -152,20 +152,20 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
|
||||
}
|
||||
|
||||
private buildItems(): ModelItem[] {
|
||||
// Filter out IDs that no longer have a corresponding model (e.g., after logout)
|
||||
return getSortedIds(this.enabledIds, this.allIds)
|
||||
.filter((id) => this.modelsById.has(id))
|
||||
.map((id) => ({
|
||||
fullId: id,
|
||||
model: this.modelsById.get(id)!,
|
||||
enabled: isEnabled(this.enabledIds, id),
|
||||
}));
|
||||
return getSortedIds(this.enabledIds, this.allIds).map((id) => ({
|
||||
fullId: id,
|
||||
model: this.modelsById.get(id),
|
||||
enabled: isEnabled(this.enabledIds, id),
|
||||
}));
|
||||
}
|
||||
|
||||
private getFooterText(): string {
|
||||
const enabledCount = this.enabledIds?.length ?? this.allIds.length;
|
||||
const enabledCount = this.enabledIds?.filter((id) => this.modelsById.has(id)).length ?? this.allIds.length;
|
||||
const unavailableCount = this.enabledIds?.filter((id) => !this.modelsById.has(id)).length ?? 0;
|
||||
const allEnabled = this.enabledIds === null;
|
||||
const countText = allEnabled ? "all enabled" : `${enabledCount}/${this.allIds.length} enabled`;
|
||||
const countText = allEnabled
|
||||
? "all enabled"
|
||||
: `${enabledCount}/${this.allIds.length} enabled${unavailableCount ? ` · ${unavailableCount} unavailable` : ""}`;
|
||||
const parts = [
|
||||
`${keyText("tui.select.confirm")} toggle`,
|
||||
`${keyText("app.models.enableAll")} all`,
|
||||
@@ -184,8 +184,10 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
|
||||
const query = this.searchInput.getValue();
|
||||
const items = this.buildItems();
|
||||
this.filteredItems = query
|
||||
? fuzzyFilter(items, query, (i) =>
|
||||
getModelSearchText({ id: i.model.id, provider: i.model.provider, name: i.model.name }),
|
||||
? fuzzyFilter(items, query, (item) =>
|
||||
item.model
|
||||
? getModelSearchText({ id: item.model.id, provider: item.model.provider, name: item.model.name })
|
||||
: item.fullId,
|
||||
)
|
||||
: items;
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
|
||||
@@ -216,9 +218,16 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
|
||||
const item = this.filteredItems[i]!;
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
|
||||
const modelText = isSelected ? theme.fg("accent", item.model.id) : item.model.id;
|
||||
const providerBadge = theme.fg("muted", ` [${item.model.provider}]`);
|
||||
const status = allEnabled ? "" : item.enabled ? theme.fg("success", " ✓") : theme.fg("dim", " ✗");
|
||||
const id = item.model?.id ?? item.fullId;
|
||||
const modelText = isSelected ? theme.fg("accent", id) : id;
|
||||
const providerBadge = theme.fg("muted", item.model ? ` [${item.model.provider}]` : " [unavailable]");
|
||||
const status = item.model
|
||||
? allEnabled
|
||||
? ""
|
||||
: item.enabled
|
||||
? theme.fg("success", " ✓")
|
||||
: theme.fg("dim", " ✗")
|
||||
: theme.fg("dim", " ✗");
|
||||
this.listContainer.addChild(new Text(`${prefix}${modelText}${providerBadge}${status}`, 0, 0));
|
||||
}
|
||||
|
||||
@@ -232,7 +241,13 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
|
||||
if (this.filteredItems.length > 0) {
|
||||
const selected = this.filteredItems[this.selectedIndex];
|
||||
this.listContainer.addChild(new Spacer(1));
|
||||
this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
|
||||
this.listContainer.addChild(
|
||||
new Text(
|
||||
theme.fg("muted", ` ${selected.model ? `Model Name: ${selected.model.name}` : "Model unavailable"}`),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +325,7 @@ export class ScopedModelsSelectorComponent extends Container implements Focusabl
|
||||
// Toggle provider of current item
|
||||
if (kb.matches(data, "app.models.toggleProvider")) {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
if (item) {
|
||||
if (item?.model) {
|
||||
const provider = item.model.provider;
|
||||
const providerIds = this.allIds.filter((id) => this.modelsById.get(id)!.provider === provider);
|
||||
const allEnabled = providerIds.every((id) => isEnabled(this.enabledIds, id));
|
||||
|
||||
@@ -76,7 +76,12 @@ import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/
|
||||
import { configureHttpDispatcher, formatHttpIdleTimeoutMs } from "../../core/http-dispatcher.ts";
|
||||
import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.ts";
|
||||
import { createCompactionSummaryMessage } from "../../core/messages.ts";
|
||||
import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts";
|
||||
import {
|
||||
defaultModelPerProvider,
|
||||
findExactModelReferenceMatch,
|
||||
resolveModelScope,
|
||||
resolveModelScopeWithDiagnostics,
|
||||
} from "../../core/model-resolver.ts";
|
||||
import { DefaultPackageManager } from "../../core/package-manager.ts";
|
||||
import type { ResourceDiagnostic } from "../../core/resource-loader.ts";
|
||||
import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts";
|
||||
@@ -2985,6 +2990,10 @@ export class InteractiveMode {
|
||||
this.ui.requestRender();
|
||||
break;
|
||||
|
||||
case "bash_execution_update":
|
||||
// The bash execution callback handles TUI output rendering.
|
||||
break;
|
||||
|
||||
case "tool_execution_start": {
|
||||
let component = this.pendingTools.get(event.toolCallId);
|
||||
if (!component) {
|
||||
@@ -3233,7 +3242,12 @@ export class InteractiveMode {
|
||||
case "custom": {
|
||||
if (message.display) {
|
||||
const renderer = this.session.extensionRunner.getMessageRenderer(message.customType);
|
||||
const component = new CustomMessageComponent(message, renderer, this.getMarkdownThemeWithSettings());
|
||||
const component = new CustomMessageComponent(
|
||||
message,
|
||||
renderer,
|
||||
this.getMarkdownThemeWithSettings(),
|
||||
this.outputPad,
|
||||
);
|
||||
component.setExpanded(this.toolOutputExpanded);
|
||||
this.chatContainer.addChild(component);
|
||||
}
|
||||
@@ -4248,7 +4262,11 @@ export class InteractiveMode {
|
||||
this.outputPad = padding;
|
||||
if (this.streamingComponent || this.session.isStreaming) {
|
||||
for (const child of this.chatContainer.children) {
|
||||
if (child instanceof AssistantMessageComponent || child instanceof UserMessageComponent) {
|
||||
if (
|
||||
child instanceof AssistantMessageComponent ||
|
||||
child instanceof CustomMessageComponent ||
|
||||
child instanceof UserMessageComponent
|
||||
) {
|
||||
child.setOutputPad(padding);
|
||||
}
|
||||
}
|
||||
@@ -4459,14 +4477,20 @@ export class InteractiveMode {
|
||||
// Get all available models
|
||||
await this.session.modelRuntime.refresh();
|
||||
const allModels = [...(await this.session.modelRuntime.getAvailable())];
|
||||
const allModelIds = new Set(allModels.map((model) => `${model.provider}/${model.id}`));
|
||||
const configuredPatterns = this.settingsManager.getEnabledModels();
|
||||
const sessionScopedModels = this.session.scopedModels;
|
||||
|
||||
if (allModels.length === 0) {
|
||||
if (allModels.length === 0 && !configuredPatterns?.length && sessionScopedModels.length === 0) {
|
||||
this.showStatus("No models available");
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredScope = configuredPatterns?.length
|
||||
? await resolveModelScopeWithDiagnostics(configuredPatterns, this.session.modelRuntime)
|
||||
: undefined;
|
||||
|
||||
// Check if session has scoped models (from previous session-only changes or CLI --models)
|
||||
const sessionScopedModels = this.session.scopedModels;
|
||||
const hasSessionScope = sessionScopedModels.length > 0;
|
||||
|
||||
// Build enabled model IDs from session state or settings
|
||||
@@ -4475,19 +4499,25 @@ export class InteractiveMode {
|
||||
if (hasSessionScope) {
|
||||
// Use current session's scoped models
|
||||
currentEnabledIds = sessionScopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
|
||||
} else {
|
||||
// Fall back to settings
|
||||
const patterns = this.settingsManager.getEnabledModels();
|
||||
if (patterns !== undefined && patterns.length > 0) {
|
||||
const scopedModels = await resolveModelScope(patterns, this.session.modelRuntime);
|
||||
currentEnabledIds = scopedModels.map((scoped) => `${scoped.model.provider}/${scoped.model.id}`);
|
||||
}
|
||||
} else if (configuredScope) {
|
||||
currentEnabledIds = configuredScope.scopedModels.map(
|
||||
(scoped) => `${scoped.model.provider}/${scoped.model.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const diagnostic of configuredScope?.diagnostics ?? []) {
|
||||
if (diagnostic.code !== "no-match") continue;
|
||||
currentEnabledIds ??= [];
|
||||
if (!currentEnabledIds.includes(diagnostic.pattern)) currentEnabledIds.push(diagnostic.pattern);
|
||||
}
|
||||
|
||||
// Helper to update session's scoped models (session-only, no persist)
|
||||
const updateSessionModels = async (enabledIds: string[] | null) => {
|
||||
currentEnabledIds = enabledIds === null ? null : [...enabledIds];
|
||||
if (enabledIds && enabledIds.length > 0 && enabledIds.length < allModels.length) {
|
||||
const hasEnabledAvailableModel = enabledIds?.some((id) => allModelIds.has(id)) ?? false;
|
||||
const allAvailableModelsEnabled =
|
||||
enabledIds !== null && [...allModelIds].every((id) => enabledIds.includes(id));
|
||||
if (enabledIds && hasEnabledAvailableModel && !allAvailableModelsEnabled) {
|
||||
const newScopedModels = await resolveModelScope(enabledIds, this.session.modelRuntime);
|
||||
this.session.setScopedModels(
|
||||
newScopedModels.map((sm) => ({
|
||||
@@ -4515,10 +4545,11 @@ export class InteractiveMode {
|
||||
},
|
||||
onPersist: (enabledIds) => {
|
||||
// Persist to settings
|
||||
const newPatterns =
|
||||
enabledIds === null || enabledIds.length === allModels.length
|
||||
? undefined // All enabled = clear filter
|
||||
: enabledIds;
|
||||
const allEnabled =
|
||||
enabledIds !== null &&
|
||||
enabledIds.length === allModels.length &&
|
||||
enabledIds.every((id) => allModelIds.has(id));
|
||||
const newPatterns = enabledIds === null || allEnabled ? undefined : enabledIds;
|
||||
this.settingsManager.setEnabledModels(newPatterns ? [...newPatterns] : undefined);
|
||||
this.showStatus("Model selection saved to settings");
|
||||
},
|
||||
|
||||
@@ -558,6 +558,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
|
||||
case "bash": {
|
||||
const result = await session.executeBash(command.command, undefined, {
|
||||
excludeFromContext: command.excludeFromContext,
|
||||
id,
|
||||
});
|
||||
return success(id, "bash", result);
|
||||
}
|
||||
|
||||
@@ -104,15 +104,25 @@ export async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
// Verify wl-copy exists (spawn errors are async and won't be caught)
|
||||
execSync("which wl-copy", { stdio: "ignore" });
|
||||
// wl-copy with execSync hangs due to fork behavior; use spawn instead
|
||||
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
||||
proc.stdin.on("error", () => {
|
||||
// Ignore EPIPE errors if wl-copy exits early
|
||||
// wl-copy with execSync hangs due to fork behavior; use spawn instead.
|
||||
// Await the exit code and only claim success on a clean exit, so a
|
||||
// failed wl-copy falls through to the xclip/OSC 52 fallbacks.
|
||||
const wlCopyExit = await new Promise<number>((resolve) => {
|
||||
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
||||
proc.on("error", () => resolve(1));
|
||||
proc.on("close", (code) => resolve(code ?? 1));
|
||||
proc.stdin.on("error", () => {
|
||||
// Ignore EPIPE errors if wl-copy exits early
|
||||
});
|
||||
proc.stdin.write(text);
|
||||
proc.stdin.end();
|
||||
});
|
||||
proc.stdin.write(text);
|
||||
proc.stdin.end();
|
||||
proc.unref();
|
||||
copied = true;
|
||||
if (wlCopyExit === 0) {
|
||||
copied = true;
|
||||
} else if (hasX11Display) {
|
||||
copyToX11Clipboard(options);
|
||||
copied = true;
|
||||
}
|
||||
} catch {
|
||||
if (hasX11Display) {
|
||||
copyToX11Clipboard(options);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { MessageRenderer, MessageRenderOptions } from "../src/core/extensions/types.ts";
|
||||
import type { CustomMessage } from "../src/core/messages.ts";
|
||||
import { CustomMessageComponent } from "../src/modes/interactive/components/custom-message.ts";
|
||||
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../src/utils/ansi.ts";
|
||||
|
||||
describe("CustomMessageComponent", () => {
|
||||
test("provides output padding to custom renderers and updates it", () => {
|
||||
initTheme("dark");
|
||||
const optionsSeen: MessageRenderOptions[] = [];
|
||||
const renderer: MessageRenderer = (_message, options) => {
|
||||
optionsSeen.push(options);
|
||||
return new Text("custom", options.outputPad, 0);
|
||||
};
|
||||
const message: CustomMessage = {
|
||||
role: "custom",
|
||||
customType: "test",
|
||||
content: "custom",
|
||||
display: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const component = new CustomMessageComponent(message, renderer, undefined, 1);
|
||||
|
||||
expect(optionsSeen).toEqual([{ expanded: false, outputPad: 1 }]);
|
||||
expect(
|
||||
component
|
||||
.render(40)
|
||||
.map(stripAnsi)
|
||||
.some((line) => line.startsWith(" custom")),
|
||||
).toBe(true);
|
||||
|
||||
component.setOutputPad(0);
|
||||
|
||||
expect(optionsSeen.at(-1)).toEqual({ expanded: false, outputPad: 0 });
|
||||
expect(
|
||||
component
|
||||
.render(40)
|
||||
.map(stripAnsi)
|
||||
.some((line) => line.startsWith("custom")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { once } from "node:events";
|
||||
import { createServer, type RequestListener, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { AuthContext, AuthPrompt } from "@earendil-works/pi-ai";
|
||||
import type { AuthContext, AuthPrompt, ModelsStoreEntry } from "@earendil-works/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createEventBus } from "../src/core/event-bus.ts";
|
||||
import { createExtensionRuntime, loadExtensionFromFactory } from "../src/core/extensions/loader.ts";
|
||||
@@ -67,7 +67,7 @@ describe("llama.cpp extension", () => {
|
||||
id: "loaded",
|
||||
status: { value: "loaded", args: ["llama-server", "--n-gpu-layers", "999"] },
|
||||
architecture: { input_modalities: ["text", "image"] },
|
||||
meta: { n_ctx: 16384, n_ctx_train: 131072 },
|
||||
meta: { n_ctx: 65536, n_ctx_train: 131072 },
|
||||
},
|
||||
{ id: "unloaded", status: { value: "unloaded" } },
|
||||
{ id: "loading", status: { value: "loading" } },
|
||||
@@ -79,13 +79,57 @@ describe("llama.cpp extension", () => {
|
||||
expect.objectContaining({
|
||||
id: "loaded",
|
||||
baseUrl: "http://localhost:8080/v1",
|
||||
contextWindow: 16384,
|
||||
maxTokens: 16384,
|
||||
contextWindow: 65536,
|
||||
maxTokens: 65536,
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists and restores loaded models for cache-only startup refreshes", async () => {
|
||||
let cachedEntry: ModelsStoreEntry | undefined;
|
||||
const store = {
|
||||
read: async () => cachedEntry,
|
||||
write: async (entry: ModelsStoreEntry) => {
|
||||
cachedEntry = structuredClone(entry);
|
||||
},
|
||||
delete: async () => {
|
||||
cachedEntry = undefined;
|
||||
},
|
||||
};
|
||||
const { url } = await listen((request, response) => {
|
||||
if (request.url === "/models") {
|
||||
json(response, {
|
||||
data: [
|
||||
{ id: "loaded", status: { value: "loaded" }, meta: { n_ctx: 32768 } },
|
||||
{ id: "unloaded", status: { value: "unloaded" } },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
|
||||
const first = createLlamaProvider();
|
||||
await first.provider.refreshModels?.({
|
||||
credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } },
|
||||
store,
|
||||
allowNetwork: true,
|
||||
});
|
||||
expect(first.provider.getModels().map((model) => model.id)).toEqual(["loaded"]);
|
||||
expect(cachedEntry?.models.map((model) => model.id)).toEqual(["loaded"]);
|
||||
|
||||
const second = createLlamaProvider();
|
||||
await second.provider.refreshModels?.({
|
||||
credential: { type: "api_key", key: "local", env: { LLAMA_BASE_URL: url } },
|
||||
store,
|
||||
allowNetwork: false,
|
||||
});
|
||||
expect(second.provider.getModels()).toEqual([
|
||||
expect.objectContaining({ id: "loaded", baseUrl: `${url}/v1`, contextWindow: 32768 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("stays dormant until configured and stores URL plus optional key", async () => {
|
||||
const { provider } = createLlamaProvider();
|
||||
const auth = provider.auth.apiKey!;
|
||||
|
||||
@@ -225,11 +225,13 @@ describe("resolveModelScopeWithDiagnostics", () => {
|
||||
{
|
||||
type: "warning",
|
||||
message: 'Invalid thinking level "invalid" in pattern "gpt-4o:invalid". Using default instead.',
|
||||
code: "invalid-thinking-level",
|
||||
pattern: "gpt-4o:invalid",
|
||||
},
|
||||
{
|
||||
type: "warning",
|
||||
message: 'No models match pattern "missing"',
|
||||
code: "no-match",
|
||||
pattern: "missing",
|
||||
},
|
||||
]);
|
||||
@@ -255,6 +257,53 @@ describe("resolveModelScopeWithDiagnostics", () => {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves bracketed model ids as exact references before glob matching", async () => {
|
||||
const bracketedModel: Model<"anthropic-messages"> = {
|
||||
id: "bracketed-model[1m]",
|
||||
name: "Bracketed Model",
|
||||
api: "anthropic-messages",
|
||||
provider: "custom",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
const registry = {
|
||||
getAvailable: () => [...allModels, bracketedModel],
|
||||
} as unknown as Parameters<typeof resolveModelScopeWithDiagnostics>[1];
|
||||
|
||||
const result = await resolveModelScopeWithDiagnostics(["custom/bracketed-model[1m]"], registry);
|
||||
|
||||
expect(result.scopedModels.map((scoped) => scoped.model.id)).toEqual(["bracketed-model[1m]"]);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
test("resolves bracketed model ids with thinking levels as exact references before glob matching", async () => {
|
||||
const bracketedModel: Model<"anthropic-messages"> = {
|
||||
id: "bracketed-model[1m]",
|
||||
name: "Bracketed Model",
|
||||
api: "anthropic-messages",
|
||||
provider: "custom",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
const registry = {
|
||||
getAvailable: () => [...allModels, bracketedModel],
|
||||
} as unknown as Parameters<typeof resolveModelScopeWithDiagnostics>[1];
|
||||
|
||||
const result = await resolveModelScopeWithDiagnostics(["custom/bracketed-model[1m]:high"], registry);
|
||||
|
||||
expect(result.scopedModels.map((scoped) => scoped.model.id)).toEqual(["bracketed-model[1m]"]);
|
||||
expect(result.scopedModels[0].thinkingLevel).toBe("high");
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCliModel", () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { statSync } from "node:fs";
|
||||
import {
|
||||
createProvider,
|
||||
InMemoryModelsStore,
|
||||
@@ -25,7 +24,7 @@ function model(id: string): Model<"openai-completions"> {
|
||||
};
|
||||
}
|
||||
|
||||
function testProvider(localCatalogUrl?: URL) {
|
||||
function testProvider(localGeneratedAt?: number) {
|
||||
return withRemoteCatalog(
|
||||
createProvider({
|
||||
id: "test-provider",
|
||||
@@ -41,7 +40,7 @@ function testProvider(localCatalogUrl?: URL) {
|
||||
},
|
||||
}),
|
||||
"https://pi.dev",
|
||||
localCatalogUrl,
|
||||
localGeneratedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,19 +79,18 @@ describe("remote catalog provider", () => {
|
||||
});
|
||||
|
||||
it("prefers the newer of the generated and remote catalogs", async () => {
|
||||
const localCatalogUrl = new URL(import.meta.url);
|
||||
const localMtime = statSync(localCatalogUrl).mtimeMs;
|
||||
const newerHeader = new Date(localMtime + 60_000).toUTCString();
|
||||
const localGeneratedAt = Date.parse("2026-07-23T10:00:00.000Z");
|
||||
const newerHeader = new Date(localGeneratedAt + 60_000).toUTCString();
|
||||
const responses = [
|
||||
new Response(JSON.stringify({ old: model("old") }), {
|
||||
headers: { "last-modified": new Date(localMtime - 60_000).toUTCString() },
|
||||
headers: { "last-modified": new Date(localGeneratedAt - 60_000).toUTCString() },
|
||||
}),
|
||||
new Response(JSON.stringify({ newer: model("newer") }), {
|
||||
headers: { "last-modified": newerHeader },
|
||||
}),
|
||||
];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
|
||||
const provider = testProvider(localCatalogUrl);
|
||||
const provider = testProvider(localGeneratedAt);
|
||||
const store = new InMemoryModelsStore();
|
||||
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
|
||||
|
||||
@@ -104,6 +102,76 @@ describe("remote catalog provider", () => {
|
||||
expect(await store.read(provider.id)).toMatchObject({ lastModified: Date.parse(newerHeader) });
|
||||
});
|
||||
|
||||
it("revalidates a stored catalog with its etag and keeps the overlay on 304", async () => {
|
||||
const responses = [
|
||||
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
|
||||
headers: { "content-type": "application/json", etag: '"catalog-1"' },
|
||||
}),
|
||||
new Response(null, { status: 304, headers: { etag: '"catalog-1"' } }),
|
||||
];
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
|
||||
const provider = testProvider();
|
||||
const store = new InMemoryModelsStore();
|
||||
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
|
||||
|
||||
await provider.refreshModels?.(refresh);
|
||||
expect(fetchSpy.mock.calls[0]?.[1]?.headers).not.toHaveProperty("if-none-match");
|
||||
expect(await store.read(provider.id)).toMatchObject({ etag: '"catalog-1"' });
|
||||
|
||||
const checkedAt = (await store.read(provider.id))?.checkedAt;
|
||||
await provider.refreshModels?.({ ...refresh, force: true });
|
||||
|
||||
expect(fetchSpy.mock.calls[1]?.[1]?.headers).toMatchObject({ "if-none-match": '"catalog-1"' });
|
||||
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
|
||||
const stored = await store.read(provider.id);
|
||||
expect(stored?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
|
||||
expect(stored?.etag).toBe('"catalog-1"');
|
||||
expect(stored?.checkedAt).toBeGreaterThanOrEqual(checkedAt ?? 0);
|
||||
});
|
||||
|
||||
it("drops a stale etag when the overlay becomes unavailable", async () => {
|
||||
const responses = [
|
||||
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
|
||||
headers: { "content-type": "application/json", etag: '"catalog-1"' },
|
||||
}),
|
||||
new Response("not implemented", { status: 501 }),
|
||||
];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
|
||||
const provider = testProvider();
|
||||
const store = new InMemoryModelsStore();
|
||||
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
|
||||
|
||||
await provider.refreshModels?.(refresh);
|
||||
await provider.refreshModels?.({ ...refresh, force: true });
|
||||
|
||||
expect((await store.read(provider.id))?.etag).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the etag and overlay after a transient failure", async () => {
|
||||
const responses = [
|
||||
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
|
||||
headers: { "content-type": "application/json", etag: '"catalog-1"' },
|
||||
}),
|
||||
new Response("rate limited", { status: 429 }),
|
||||
new Response(null, { status: 304, headers: { etag: '"catalog-1"' } }),
|
||||
];
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response);
|
||||
const provider = testProvider();
|
||||
const store = new InMemoryModelsStore();
|
||||
const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true };
|
||||
|
||||
await provider.refreshModels?.(refresh);
|
||||
await expect(provider.refreshModels?.({ ...refresh, force: true })).rejects.toThrow(/429/);
|
||||
|
||||
const stored = await store.read(provider.id);
|
||||
expect(stored?.etag).toBe('"catalog-1"');
|
||||
expect(stored?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
|
||||
|
||||
await provider.refreshModels?.({ ...refresh, force: true });
|
||||
expect(fetchSpy.mock.calls[2]?.[1]?.headers).toMatchObject({ "if-none-match": '"catalog-1"' });
|
||||
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
|
||||
});
|
||||
|
||||
it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not implemented", { status: 501 }));
|
||||
const provider = testProvider();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "nod
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { ExtensionRunner } from "../src/core/extensions/runner.ts";
|
||||
import { DefaultResourceLoader } from "../src/core/resource-loader.ts";
|
||||
@@ -355,6 +355,22 @@ Content`,
|
||||
expect(agentsFiles.some((f) => f.path.includes("AGENTS.md"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should ignore context file candidates that are directories", async () => {
|
||||
mkdirSync(join(cwd, "AGENTS.md"));
|
||||
writeFileSync(join(cwd, "CLAUDE.md"), "Fallback instructions");
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const loader = new DefaultResourceLoader({ cwd, agentDir });
|
||||
await loader.reload();
|
||||
|
||||
expect(loader.getAgentsFiles().agentsFiles).toContainEqual({
|
||||
path: join(cwd, "CLAUDE.md"),
|
||||
content: "Fallback instructions",
|
||||
});
|
||||
expect(consoleError).not.toHaveBeenCalledWith(expect.stringContaining(join(cwd, "AGENTS.md")));
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("should skip AGENTS.md and CLAUDE.md discovery when noContextFiles is true", async () => {
|
||||
writeFileSync(join(cwd, "AGENTS.md"), "# Project Guidelines\n\nBe helpful.");
|
||||
writeFileSync(join(cwd, "CLAUDE.md"), "# Claude Guidelines\n\nBe helpful.");
|
||||
|
||||
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.ts";
|
||||
import { createAgentSession } from "../src/core/sdk.ts";
|
||||
import { SessionManager } from "../src/core/session-manager.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
import { type Settings, SettingsManager } from "../src/core/settings-manager.ts";
|
||||
|
||||
import { createModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts";
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("createAgentSession stream options", () => {
|
||||
|
||||
async function captureStreamOptions(
|
||||
api: Api,
|
||||
settings: { httpIdleTimeoutMs?: number; websocketConnectTimeoutMs?: number },
|
||||
settings: Partial<Settings>,
|
||||
requestOptions: SimpleStreamOptions = {},
|
||||
extensionSource?: string,
|
||||
): Promise<SimpleStreamOptions | undefined> {
|
||||
@@ -161,6 +161,15 @@ describe("createAgentSession stream options", () => {
|
||||
expect(options?.websocketConnectTimeoutMs).toBe(0);
|
||||
});
|
||||
|
||||
it("forwards provider retry settings", async () => {
|
||||
const options = await captureStreamOptions("openai-completions", {
|
||||
retry: { provider: { maxRetries: 2, maxRetryDelayMs: 3000 } },
|
||||
});
|
||||
|
||||
expect(options?.maxRetries).toBe(2);
|
||||
expect(options?.maxRetryDelayMs).toBe(3000);
|
||||
});
|
||||
|
||||
it("runs before_provider_headers on assembled headers without forwarding the transform", async () => {
|
||||
const options = await captureStreamOptions(
|
||||
"openai-completions",
|
||||
|
||||
@@ -239,4 +239,35 @@ describe("AgentSession bash and persistence characterization", () => {
|
||||
expect(result.output).toContain("hello from custom ops");
|
||||
expect(harness.session.messages[harness.session.messages.length - 1]?.role).toBe("bashExecution");
|
||||
});
|
||||
|
||||
it("streams bash output to the callback and session events", async () => {
|
||||
const harness = await createHarness();
|
||||
harnesses.push(harness);
|
||||
const callbackDeltas: string[] = [];
|
||||
const eventUpdates: Array<{ id: string | undefined; delta: string }> = [];
|
||||
const unsubscribe = harness.session.subscribe((event) => {
|
||||
if (event.type === "bash_execution_update") {
|
||||
eventUpdates.push({ id: event.id, delta: event.delta });
|
||||
}
|
||||
});
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
options.onData(Buffer.from("hello "));
|
||||
options.onData(Buffer.from("world"));
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
await harness.session.executeBash("custom", (delta) => callbackDeltas.push(delta), {
|
||||
id: "bash-1",
|
||||
operations,
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
expect(callbackDeltas).toEqual(["hello ", "world"]);
|
||||
expect(eventUpdates).toEqual([
|
||||
{ id: "bash-1", delta: "hello " },
|
||||
{ id: "bash-1", delta: "world" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,6 +175,41 @@ describe("AgentSession compaction characterization", () => {
|
||||
expect(getStreamCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("manually compacts with provider-resolved bearer auth", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
const model = harness.getModel();
|
||||
harness.session.modelRuntime.registerNativeProvider({
|
||||
id: model.provider,
|
||||
name: "Faux bearer provider",
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: "Faux bearer token",
|
||||
resolve: async () => ({
|
||||
auth: { headers: { Authorization: "Bearer ambient-token" } },
|
||||
source: "ambient bearer token",
|
||||
}),
|
||||
},
|
||||
},
|
||||
getModels: () => harness.models,
|
||||
stream: () => createAssistantMessageEventStream(),
|
||||
streamSimple: () => createAssistantMessageEventStream(),
|
||||
});
|
||||
seedCompactableSession(harness);
|
||||
harness.setResponses([
|
||||
(_context, options) => {
|
||||
expect(options?.apiKey).toBeUndefined();
|
||||
expect(options?.headers).toEqual({ Authorization: "Bearer ambient-token" });
|
||||
return fauxAssistantMessage("summary with bearer auth");
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await harness.session.compact();
|
||||
|
||||
expect(result.summary).toContain("summary with bearer auth");
|
||||
expect(harness.faux.state.callCount).toBe(1);
|
||||
});
|
||||
|
||||
it("persists usage from pi-generated manual compaction", async () => {
|
||||
const harness = await createHarness({ withConfiguredAuth: false });
|
||||
harnesses.push(harness);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createInMemoryModelRegistry, getModelRuntime } from "../model-runtime-test-utils.ts";
|
||||
import { createInMemoryModelRegistry, createModelRegistry, getModelRuntime } from "../model-runtime-test-utils.ts";
|
||||
/**
|
||||
* Local test harness for the new coding-agent test suite.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
|
||||
@@ -71,6 +71,7 @@ export interface HarnessOptions {
|
||||
resourceLoader?: ResourceLoader;
|
||||
extensionFactories?: Array<InlineExtension | CreateTestExtensionsResultInput>;
|
||||
withConfiguredAuth?: boolean;
|
||||
modelsJson?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
@@ -115,7 +116,11 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
||||
if (withConfiguredAuth) {
|
||||
await authStorage.modify(model.provider, async () => ({ type: "api_key", key: "faux-key" }));
|
||||
}
|
||||
const modelRegistry = await createInMemoryModelRegistry(authStorage);
|
||||
const modelsPath = options.modelsJson === undefined ? undefined : join(tempDir, "models.json");
|
||||
if (modelsPath) writeFileSync(modelsPath, JSON.stringify(options.modelsJson));
|
||||
const modelRegistry = modelsPath
|
||||
? await createModelRegistry(authStorage, modelsPath)
|
||||
: await createInMemoryModelRegistry(authStorage);
|
||||
if (withConfiguredAuth) {
|
||||
modelRegistry.registerProvider(model.provider, {
|
||||
baseUrl: model.baseUrl,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { Api, Model } from "@earendil-works/pi-ai";
|
||||
import { setKeybindings } from "@earendil-works/pi-tui";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
|
||||
import { ScopedModelsSelectorComponent } from "../../../src/modes/interactive/components/scoped-models-selector.ts";
|
||||
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
|
||||
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../../../src/utils/ansi.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
function createInteractiveContext(options: {
|
||||
allModels: Model<Api>[];
|
||||
enabledModelIds: string[];
|
||||
scopedModels?: Array<{ model: Model<Api> }>;
|
||||
}) {
|
||||
let selector: ScopedModelsSelectorComponent | undefined;
|
||||
const setScopedModels = vi.fn();
|
||||
const getAvailable = vi.fn().mockResolvedValue(options.allModels);
|
||||
const context = {
|
||||
session: {
|
||||
modelRuntime: {
|
||||
refresh: vi.fn(),
|
||||
getAvailable,
|
||||
},
|
||||
scopedModels: options.scopedModels ?? [],
|
||||
setScopedModels,
|
||||
},
|
||||
settingsManager: {
|
||||
getEnabledModels: () => options.enabledModelIds,
|
||||
setEnabledModels: vi.fn(),
|
||||
},
|
||||
showStatus: vi.fn(),
|
||||
showSelector: (factory: (done: () => void) => { component: ScopedModelsSelectorComponent }) => {
|
||||
selector = factory(() => {}).component;
|
||||
},
|
||||
updateAvailableProviderCount: vi.fn(),
|
||||
ui: { requestRender: vi.fn() },
|
||||
};
|
||||
return { context, getAvailable, getSelector: () => selector, setScopedModels };
|
||||
}
|
||||
|
||||
async function showModelsSelector(context: object): Promise<void> {
|
||||
const show = Reflect.get(InteractiveMode.prototype, "showModelsSelector") as (this: object) => Promise<void>;
|
||||
await show.call(context);
|
||||
}
|
||||
|
||||
describe("issue #6949 unavailable scoped models", () => {
|
||||
const harnesses: Harness[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setKeybindings(new KeybindingsManager());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (harnesses.length > 0) harnesses.pop()?.cleanup();
|
||||
});
|
||||
|
||||
it("shows and removes an enabled model without a catalog entry", async () => {
|
||||
const harness = await createHarness({ models: [{ id: "available", name: "Available" }] });
|
||||
harnesses.push(harness);
|
||||
const availableId = `${harness.models[0].provider}/${harness.models[0].id}`;
|
||||
const unavailableId = `${harness.models[0].provider}/unavailable`;
|
||||
const changes: Array<string[] | null> = [];
|
||||
const persisted: Array<string[] | null> = [];
|
||||
const selector = new ScopedModelsSelectorComponent(
|
||||
{
|
||||
allModels: [...harness.models],
|
||||
enabledModelIds: [unavailableId, availableId],
|
||||
},
|
||||
{
|
||||
onChange: (enabledIds) => {
|
||||
changes.push(enabledIds);
|
||||
},
|
||||
onPersist: (enabledIds) => {
|
||||
persisted.push(enabledIds);
|
||||
},
|
||||
onCancel: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(stripAnsi(selector.render(100).join("\n"))).toContain(`${unavailableId} [unavailable] ✗`);
|
||||
selector.handleInput("\r");
|
||||
expect(changes).toEqual([[availableId]]);
|
||||
selector.handleInput("\x13");
|
||||
expect(persisted).toEqual([[availableId]]);
|
||||
});
|
||||
|
||||
it("passes unmatched settings patterns to the selector with one combined resolution", async () => {
|
||||
const harness = await createHarness({ models: [{ id: "available", name: "Available" }] });
|
||||
harnesses.push(harness);
|
||||
const unavailableIds = ["unavailable-one", "unavailable-two"].map((id) => `${harness.models[0].provider}/${id}`);
|
||||
const { context, getAvailable, getSelector } = createInteractiveContext({
|
||||
allModels: [],
|
||||
enabledModelIds: unavailableIds,
|
||||
});
|
||||
|
||||
await showModelsSelector(context);
|
||||
|
||||
const selector = getSelector();
|
||||
if (!selector) throw new Error("Expected scoped-model selector to open");
|
||||
const rendered = stripAnsi(selector.render(100).join("\n"));
|
||||
for (const unavailableId of unavailableIds) {
|
||||
expect(rendered).toContain(`${unavailableId} [unavailable] ✗`);
|
||||
}
|
||||
expect(getAvailable).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("opens when only a session-scoped model is unavailable", async () => {
|
||||
const harness = await createHarness({ models: [{ id: "unavailable", name: "Unavailable" }] });
|
||||
harnesses.push(harness);
|
||||
const model = harness.models[0];
|
||||
const fullId = `${model.provider}/${model.id}`;
|
||||
const { context, getSelector } = createInteractiveContext({
|
||||
allModels: [],
|
||||
enabledModelIds: [],
|
||||
scopedModels: [{ model }],
|
||||
});
|
||||
|
||||
await showModelsSelector(context);
|
||||
|
||||
const selector = getSelector();
|
||||
if (!selector) throw new Error("Expected scoped-model selector to open");
|
||||
expect(stripAnsi(selector.render(100).join("\n"))).toContain(`${fullId} [unavailable] ✗`);
|
||||
});
|
||||
|
||||
it("does not clear a partial scope when an enabled model is unavailable", async () => {
|
||||
const harness = await createHarness({
|
||||
models: [
|
||||
{ id: "one", name: "One" },
|
||||
{ id: "two", name: "Two" },
|
||||
{ id: "three", name: "Three" },
|
||||
],
|
||||
});
|
||||
harnesses.push(harness);
|
||||
const [one, two] = harness.models;
|
||||
const enabledIds = [one, two].map((model) => `${model.provider}/${model.id}`);
|
||||
const unavailableId = `${one.provider}/unavailable`;
|
||||
const { context, getSelector, setScopedModels } = createInteractiveContext({
|
||||
allModels: [...harness.models],
|
||||
enabledModelIds: [...enabledIds, unavailableId],
|
||||
scopedModels: [{ model: one }, { model: two }],
|
||||
});
|
||||
|
||||
await showModelsSelector(context);
|
||||
const selector = getSelector();
|
||||
if (!selector) throw new Error("Expected scoped-model selector to open");
|
||||
selector.handleInput("\x1b[1;3B");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(setScopedModels).toHaveBeenLastCalledWith([
|
||||
{ model: two, thinkingLevel: undefined },
|
||||
{ model: one, thinkingLevel: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { setKeybindings, type TUI } from "@earendil-works/pi-tui";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { KeybindingsManager } from "../../../src/core/keybindings.ts";
|
||||
import { ModelSelectorComponent } from "../../../src/modes/interactive/components/model-selector.ts";
|
||||
import { initTheme } from "../../../src/modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../../../src/utils/ansi.ts";
|
||||
import { createHarness, type Harness } from "../harness.ts";
|
||||
|
||||
function createFakeTui(): TUI {
|
||||
return {
|
||||
requestRender: () => {},
|
||||
} as unknown as TUI;
|
||||
}
|
||||
|
||||
function modelsJson(provider: string, model: string): Record<string, unknown> {
|
||||
return {
|
||||
providers: {
|
||||
[provider]: {
|
||||
baseUrl: "https://example.test/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "test-key",
|
||||
models: [{ id: model }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("issue #6999 models.json hot reload", () => {
|
||||
let harness: Harness | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme("dark");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setKeybindings(new KeybindingsManager());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
harness?.cleanup();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
it("reloads models.json when opening /model", async () => {
|
||||
harness = await createHarness({ modelsJson: modelsJson("old-provider", "old-model") });
|
||||
expect(harness.session.modelRuntime.getModel("old-provider", "old-model")).toBeDefined();
|
||||
|
||||
writeFileSync(join(harness.tempDir, "models.json"), JSON.stringify(modelsJson("new-provider", "new-model")));
|
||||
const selector = new ModelSelectorComponent(
|
||||
createFakeTui(),
|
||||
harness.getModel(),
|
||||
harness.settingsManager,
|
||||
harness.session.modelRuntime,
|
||||
[],
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const rendered = stripAnsi(selector.render(120).join("\n"));
|
||||
expect(rendered).toContain("new-model [new-provider]");
|
||||
expect(rendered).toContain("Model catalogs refreshed.");
|
||||
});
|
||||
expect(harness.session.modelRuntime.getModel("old-provider", "old-model")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +1,28 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import baseConfig, { workspaceSourcePaths } from "../../vitest.base.ts";
|
||||
|
||||
const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url));
|
||||
const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url));
|
||||
const aiSrcOAuth = fileURLToPath(new URL("../ai/src/oauth.ts", import.meta.url));
|
||||
const aiSrcProviders = fileURLToPath(new URL("../ai/src/providers", import.meta.url));
|
||||
const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url));
|
||||
const tuiSrcIndex = fileURLToPath(new URL("../tui/src/index.ts", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
testTimeout: 30000,
|
||||
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
|
||||
silent: "passed-only",
|
||||
server: {
|
||||
deps: {
|
||||
external: [/@silvia-odwyer\/photon-node/],
|
||||
export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
testTimeout: 30000,
|
||||
reporters: process.env.GITHUB_ACTIONS ? ["dot", "github-actions"] : ["dot"],
|
||||
silent: "passed-only",
|
||||
server: {
|
||||
deps: {
|
||||
external: [/@silvia-odwyer\/photon-node/],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex },
|
||||
{ find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat },
|
||||
{ find: /^@earendil-works\/pi-ai\/oauth$/, replacement: aiSrcOAuth },
|
||||
{ find: /^@earendil-works\/pi-ai\/providers\/(.+)$/, replacement: `${aiSrcProviders}/$1.ts` },
|
||||
{ find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex },
|
||||
{ find: /^@earendil-works\/pi-tui$/, replacement: tuiSrcIndex },
|
||||
{ find: /^@mariozechner\/pi-ai$/, replacement: aiSrcIndex },
|
||||
{ find: /^@mariozechner\/pi-ai\/oauth$/, replacement: aiSrcOAuth },
|
||||
{ find: /^@mariozechner\/pi-agent-core$/, replacement: agentSrcIndex },
|
||||
{ find: /^@mariozechner\/pi-tui$/, replacement: tuiSrcIndex },
|
||||
],
|
||||
},
|
||||
});
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: /^@mariozechner\/pi-ai$/, replacement: workspaceSourcePaths.aiIndex },
|
||||
{ find: /^@mariozechner\/pi-ai\/oauth$/, replacement: workspaceSourcePaths.aiOAuth },
|
||||
{ find: /^@mariozechner\/pi-agent-core$/, replacement: workspaceSourcePaths.agentIndex },
|
||||
{ find: /^@mariozechner\/pi-tui$/, replacement: workspaceSourcePaths.tuiIndex },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user