feat(agent): merge main into agent-harness-tools
This commit is contained in:
@@ -287,3 +287,5 @@ QuintinShaw pr
|
|||||||
R-Taneja pr
|
R-Taneja pr
|
||||||
|
|
||||||
zaycruz pr
|
zaycruz pr
|
||||||
|
|
||||||
|
mteam88 pr
|
||||||
|
|||||||
@@ -49,8 +49,27 @@ jobs:
|
|||||||
node-version: '22'
|
node-version: '22'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
- name: Build binaries
|
- name: Create source archive
|
||||||
run: ./scripts/build-binaries.sh
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
mkdir -p release-assets
|
||||||
|
./scripts/create-source-archive.sh \
|
||||||
|
--version "${VERSION}" \
|
||||||
|
--ref HEAD \
|
||||||
|
--out "release-assets/pi-${VERSION}-source.tar.gz"
|
||||||
|
|
||||||
|
- name: Build binaries from source archive
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
build_root="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${build_root}"' EXIT
|
||||||
|
tar -xzf "release-assets/pi-${VERSION}-source.tar.gz" -C "${build_root}"
|
||||||
|
"${build_root}/pi-${VERSION}/scripts/build-binaries.sh" \
|
||||||
|
--out "${GITHUB_WORKSPACE}/packages/coding-agent/binaries"
|
||||||
|
|
||||||
- name: Prepare GitHub release payload
|
- name: Prepare GitHub release payload
|
||||||
run: |
|
run: |
|
||||||
@@ -83,7 +102,9 @@ jobs:
|
|||||||
cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/"
|
cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/"
|
||||||
|
|
||||||
cd "${GITHUB_WORKSPACE}/release-assets"
|
cd "${GITHUB_WORKSPACE}/release-assets"
|
||||||
|
source_asset="pi-${VERSION}-source.tar.gz"
|
||||||
release_assets=(
|
release_assets=(
|
||||||
|
"${source_asset}"
|
||||||
pi-darwin-arm64.tar.gz
|
pi-darwin-arm64.tar.gz
|
||||||
pi-darwin-x64.tar.gz
|
pi-darwin-x64.tar.gz
|
||||||
pi-linux-x64.tar.gz
|
pi-linux-x64.tar.gz
|
||||||
@@ -125,7 +146,10 @@ jobs:
|
|||||||
|
|
||||||
cd release-assets
|
cd release-assets
|
||||||
|
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
source_asset="pi-${VERSION}-source.tar.gz"
|
||||||
expected_assets=(
|
expected_assets=(
|
||||||
|
"${source_asset}"
|
||||||
pi-darwin-arm64.tar.gz
|
pi-darwin-arm64.tar.gz
|
||||||
pi-darwin-x64.tar.gz
|
pi-darwin-x64.tar.gz
|
||||||
pi-linux-x64.tar.gz
|
pi-linux-x64.tar.gz
|
||||||
@@ -144,7 +168,7 @@ jobs:
|
|||||||
|
|
||||||
sha256sum -c SHA256SUMS
|
sha256sum -c SHA256SUMS
|
||||||
|
|
||||||
- name: Create draft GitHub Release and upload binaries
|
- name: Create draft GitHub Release and upload assets
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -152,7 +176,10 @@ jobs:
|
|||||||
|
|
||||||
cd release-assets
|
cd release-assets
|
||||||
|
|
||||||
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
source_asset="pi-${VERSION}-source.tar.gz"
|
||||||
release_assets=(
|
release_assets=(
|
||||||
|
"${source_asset}"
|
||||||
pi-darwin-arm64.tar.gz
|
pi-darwin-arm64.tar.gz
|
||||||
pi-darwin-x64.tar.gz
|
pi-darwin-x64.tar.gz
|
||||||
pi-linux-x64.tar.gz
|
pi-linux-x64.tar.gz
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ npm run check # Lint, format, and type check
|
|||||||
./pi-test.sh # Run pi from sources (can be run from any directory)
|
./pi-test.sh # Run pi from sources (can be run from any directory)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Building standalone binaries from release source
|
||||||
|
|
||||||
|
GitHub releases include a versioned source archive covered by the release's `SHA256SUMS` file. Extract it and run the same build script used for the official standalone binaries:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERSION="<release-version>"
|
||||||
|
tar -xzf "pi-${VERSION}-source.tar.gz"
|
||||||
|
cd "pi-${VERSION}"
|
||||||
|
./scripts/build-binaries.sh --platform linux-x64 --out "$PWD/out"
|
||||||
|
```
|
||||||
|
|
||||||
|
The script installs dependencies, builds the monorepo, compiles the Bun executable, and stages its runtime assets. Package maintainers who provide dependencies separately can pass `--skip-install --skip-deps`.
|
||||||
|
|
||||||
## Supply-chain hardening
|
## Supply-chain hardening
|
||||||
|
|
||||||
We treat npm dependency changes as reviewed code changes.
|
We treat npm dependency changes as reviewed code changes.
|
||||||
|
|||||||
Generated
+18
-18
@@ -5041,10 +5041,10 @@
|
|||||||
},
|
},
|
||||||
"packages/agent": {
|
"packages/agent": {
|
||||||
"name": "@earendil-works/pi-agent-core",
|
"name": "@earendil-works/pi-agent-core",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"diff": "8.0.4",
|
"diff": "8.0.4",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
@@ -5394,7 +5394,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ai": {
|
"packages/ai": {
|
||||||
"name": "@earendil-works/pi-ai",
|
"name": "@earendil-works/pi-ai",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.91.1",
|
"@anthropic-ai/sdk": "0.91.1",
|
||||||
@@ -5700,12 +5700,12 @@
|
|||||||
},
|
},
|
||||||
"packages/coding-agent": {
|
"packages/coding-agent": {
|
||||||
"name": "@earendil-works/pi-coding-agent",
|
"name": "@earendil-works/pi-coding-agent",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"@earendil-works/pi-tui": "^0.81.0",
|
"@earendil-works/pi-tui": "^0.81.1",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
@@ -5746,32 +5746,32 @@
|
|||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
|
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
|
||||||
"name": "pi-extension-custom-provider-anthropic",
|
"name": "pi-extension-custom-provider-anthropic",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.52.0"
|
"@anthropic-ai/sdk": "0.52.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
|
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
|
||||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||||
"version": "0.81.0"
|
"version": "0.81.1"
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/gondolin": {
|
"packages/coding-agent/examples/extensions/gondolin": {
|
||||||
"name": "pi-extension-gondolin",
|
"name": "pi-extension-gondolin",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/gondolin": "0.12.0"
|
"@earendil-works/gondolin": "0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/sandbox": {
|
"packages/coding-agent/examples/extensions/sandbox": {
|
||||||
"name": "pi-extension-sandbox",
|
"name": "pi-extension-sandbox",
|
||||||
"version": "1.11.0",
|
"version": "1.11.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sandbox-runtime": "0.0.26"
|
"@anthropic-ai/sandbox-runtime": "0.0.26"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/with-deps": {
|
"packages/coding-agent/examples/extensions/with-deps": {
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.1.3"
|
"ms": "2.1.3"
|
||||||
},
|
},
|
||||||
@@ -6067,10 +6067,10 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@earendil-works/pi-server",
|
"name": "@earendil-works/pi-server",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "^0.81.0"
|
"@earendil-works/pi-coding-agent": "^0.81.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"server": "dist/cli.js"
|
"server": "dist/cli.js"
|
||||||
@@ -6084,11 +6084,11 @@
|
|||||||
},
|
},
|
||||||
"packages/storage/sqlite-node": {
|
"packages/storage/sqlite-node": {
|
||||||
"name": "@earendil-works/pi-storage-sqlite-node",
|
"name": "@earendil-works/pi-storage-sqlite-node",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||||
"@earendil-works/pi-ai": "^0.81.0"
|
"@earendil-works/pi-ai": "^0.81.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
@@ -6096,7 +6096,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@earendil-works/pi-tui",
|
"name": "@earendil-works/pi-tui",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"get-east-asian-width": "1.6.0",
|
"get-east-asian-width": "1.6.0",
|
||||||
|
|||||||
@@ -10,6 +10,16 @@
|
|||||||
|
|
||||||
- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`.
|
- Added context-aware `read`, `write`, `edit`, and `bash` harness tools backed by `ExecutionEnv`.
|
||||||
|
|
||||||
|
## [0.81.1] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added retry policy support and lifecycle events for compaction and branch-summary operations in `AgentHarness` ([#6901](https://github.com/earendil-works/pi/pull/6901) by [@davidbrai](https://github.com/davidbrai)).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Restored the `Agent` `streamFn` option and host-configurable fallback for omitted agent-loop stream functions without reintroducing a `pi-ai/compat` dependency ([#6915](https://github.com/earendil-works/pi/issues/6915)).
|
||||||
|
|
||||||
## [0.81.0] - 2026-07-21
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const agent = new Agent({
|
|||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
model,
|
model,
|
||||||
},
|
},
|
||||||
streamFunction: models.streamSimple.bind(models),
|
streamFn: models.streamSimple.bind(models),
|
||||||
});
|
});
|
||||||
|
|
||||||
agent.subscribe((event) => {
|
agent.subscribe((event) => {
|
||||||
@@ -199,7 +199,7 @@ const agent = new Agent({
|
|||||||
followUpMode: "one-at-a-time",
|
followUpMode: "one-at-a-time",
|
||||||
|
|
||||||
// Required stream function
|
// Required stream function
|
||||||
streamFunction: models.streamSimple.bind(models),
|
streamFn: models.streamSimple.bind(models),
|
||||||
|
|
||||||
// Session ID for provider caching
|
// Session ID for provider caching
|
||||||
sessionId: "session-123",
|
sessionId: "session-123",
|
||||||
@@ -386,7 +386,7 @@ Handle custom types in `convertToLlm`:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: models.streamSimple.bind(models),
|
streamFn: models.streamSimple.bind(models),
|
||||||
convertToLlm: (messages) => messages.flatMap(m => {
|
convertToLlm: (messages) => messages.flatMap(m => {
|
||||||
if (m.role === "notification") return []; // Filter out
|
if (m.role === "notification") return []; // Filter out
|
||||||
return [m];
|
return [m];
|
||||||
@@ -457,7 +457,7 @@ For browser apps that proxy through a backend:
|
|||||||
import { Agent, streamProxy } from "@earendil-works/pi-agent-core";
|
import { Agent, streamProxy } from "@earendil-works/pi-agent-core";
|
||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: (model, context, options) =>
|
streamFn: (model, context, options) =>
|
||||||
streamProxy(model, context, {
|
streamProxy(model, context, {
|
||||||
...options,
|
...options,
|
||||||
authToken: "...",
|
authToken: "...",
|
||||||
@@ -489,13 +489,13 @@ const config: AgentLoopConfig = {
|
|||||||
|
|
||||||
const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };
|
const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };
|
||||||
|
|
||||||
const streamFunction = models.streamSimple.bind(models);
|
const streamFn = models.streamSimple.bind(models);
|
||||||
for await (const event of agentLoop([userMessage], context, config, undefined, streamFunction)) {
|
for await (const event of agentLoop([userMessage], context, config, undefined, streamFn)) {
|
||||||
console.log(event.type);
|
console.log(event.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue from existing context
|
// Continue from existing context
|
||||||
for await (const event of agentLoopContinue(context, config, undefined, streamFunction)) {
|
for await (const event of agentLoopContinue(context, config, undefined, streamFn)) {
|
||||||
console.log(event.type);
|
console.log(event.type);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -183,6 +183,16 @@ Summary:
|
|||||||
|
|
||||||
Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing.
|
Event payloads describe what is happening. Harness getters describe latest config for future snapshots. Hook and listener settlement should be awaited in lifecycle order where possible; transport backpressure is handled below the harness by `AssistantMessageStream`, so the harness does not need a separate async event queue merely to keep SSE or websocket reads flowing.
|
||||||
|
|
||||||
|
### Summarization retry events
|
||||||
|
|
||||||
|
When the harness is configured with a retry policy, generated compaction and branch-summary requests emit retry lifecycle events for transient provider errors:
|
||||||
|
|
||||||
|
- `retry_scheduled`: a retry was scheduled. Includes `operation: "compaction" | "branch_summary"`, `attempt`, `maxAttempts`, `delayMs`, and `errorMessage`.
|
||||||
|
- `retry_attempt_start`: the backoff delay completed and the retried summarization request is starting. Includes `operation`.
|
||||||
|
- `retry_finished`: the retry loop finished after success, exhaustion, or abort. Includes `operation`.
|
||||||
|
|
||||||
|
These events are observational and do not accept hook results.
|
||||||
|
|
||||||
## Planned session facade
|
## Planned session facade
|
||||||
|
|
||||||
Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes.
|
Extensions should eventually interact with a harness-scoped `HarnessSession` facade rather than the raw session. The facade should wrap the internal session and enforce harness pending-write ordering semantics. Once this exists, hooks and event listeners can receive a context that exposes the full `AgentHarness` plus the session facade without giving direct access to unordered raw session writes.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-agent-core",
|
"name": "@earendil-works/pi-agent-core",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"diff": "8.0.4",
|
"diff": "8.0.4",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type ToolResultMessage,
|
type ToolResultMessage,
|
||||||
validateToolArguments,
|
validateToolArguments,
|
||||||
} from "@earendil-works/pi-ai";
|
} from "@earendil-works/pi-ai";
|
||||||
|
import { getDefaultStreamFn } from "./stream-fn.ts";
|
||||||
import type {
|
import type {
|
||||||
AgentContext,
|
AgentContext,
|
||||||
AgentEvent,
|
AgentEvent,
|
||||||
@@ -32,7 +33,7 @@ export function agentLoop(
|
|||||||
context: AgentContext,
|
context: AgentContext,
|
||||||
config: AgentLoopConfig,
|
config: AgentLoopConfig,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
streamFunction: StreamFn,
|
streamFn: StreamFn,
|
||||||
): EventStream<AgentEvent, AgentMessage[]> {
|
): EventStream<AgentEvent, AgentMessage[]> {
|
||||||
const stream = createAgentStream();
|
const stream = createAgentStream();
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export function agentLoop(
|
|||||||
stream.push(event);
|
stream.push(event);
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
streamFunction,
|
streamFn,
|
||||||
).then((messages) => {
|
).then((messages) => {
|
||||||
stream.end(messages);
|
stream.end(messages);
|
||||||
});
|
});
|
||||||
@@ -64,7 +65,7 @@ export function agentLoopContinue(
|
|||||||
context: AgentContext,
|
context: AgentContext,
|
||||||
config: AgentLoopConfig,
|
config: AgentLoopConfig,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
streamFunction: StreamFn,
|
streamFn: StreamFn,
|
||||||
): EventStream<AgentEvent, AgentMessage[]> {
|
): EventStream<AgentEvent, AgentMessage[]> {
|
||||||
if (context.messages.length === 0) {
|
if (context.messages.length === 0) {
|
||||||
throw new Error("Cannot continue: no messages in context");
|
throw new Error("Cannot continue: no messages in context");
|
||||||
@@ -83,7 +84,7 @@ export function agentLoopContinue(
|
|||||||
stream.push(event);
|
stream.push(event);
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
streamFunction,
|
streamFn,
|
||||||
).then((messages) => {
|
).then((messages) => {
|
||||||
stream.end(messages);
|
stream.end(messages);
|
||||||
});
|
});
|
||||||
@@ -97,7 +98,7 @@ export async function runAgentLoop(
|
|||||||
config: AgentLoopConfig,
|
config: AgentLoopConfig,
|
||||||
emit: AgentEventSink,
|
emit: AgentEventSink,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
streamFunction: StreamFn,
|
streamFn: StreamFn,
|
||||||
): Promise<AgentMessage[]> {
|
): Promise<AgentMessage[]> {
|
||||||
const newMessages: AgentMessage[] = [...prompts];
|
const newMessages: AgentMessage[] = [...prompts];
|
||||||
const currentContext: AgentContext = {
|
const currentContext: AgentContext = {
|
||||||
@@ -112,7 +113,7 @@ export async function runAgentLoop(
|
|||||||
await emit({ type: "message_end", message: prompt });
|
await emit({ type: "message_end", message: prompt });
|
||||||
}
|
}
|
||||||
|
|
||||||
await runLoop(currentContext, newMessages, config, signal, emit, streamFunction);
|
await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
|
||||||
return newMessages;
|
return newMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +122,7 @@ export async function runAgentLoopContinue(
|
|||||||
config: AgentLoopConfig,
|
config: AgentLoopConfig,
|
||||||
emit: AgentEventSink,
|
emit: AgentEventSink,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
streamFunction: StreamFn,
|
streamFn: StreamFn,
|
||||||
): Promise<AgentMessage[]> {
|
): Promise<AgentMessage[]> {
|
||||||
if (context.messages.length === 0) {
|
if (context.messages.length === 0) {
|
||||||
throw new Error("Cannot continue: no messages in context");
|
throw new Error("Cannot continue: no messages in context");
|
||||||
@@ -137,7 +138,7 @@ export async function runAgentLoopContinue(
|
|||||||
await emit({ type: "agent_start" });
|
await emit({ type: "agent_start" });
|
||||||
await emit({ type: "turn_start" });
|
await emit({ type: "turn_start" });
|
||||||
|
|
||||||
await runLoop(currentContext, newMessages, config, signal, emit, streamFunction);
|
await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
|
||||||
return newMessages;
|
return newMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-19
@@ -8,6 +8,7 @@ import type {
|
|||||||
Transport,
|
Transport,
|
||||||
} from "@earendil-works/pi-ai";
|
} from "@earendil-works/pi-ai";
|
||||||
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
|
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
|
||||||
|
import { getDefaultStreamFn } from "./stream-fn.ts";
|
||||||
import type {
|
import type {
|
||||||
AfterToolCallContext,
|
AfterToolCallContext,
|
||||||
AfterToolCallResult,
|
AfterToolCallResult,
|
||||||
@@ -97,7 +98,7 @@ export interface AgentOptions {
|
|||||||
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
|
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
|
||||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||||
streamFunction: StreamFn;
|
streamFn: StreamFn;
|
||||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||||
onPayload?: SimpleStreamOptions["onPayload"];
|
onPayload?: SimpleStreamOptions["onPayload"];
|
||||||
onResponse?: SimpleStreamOptions["onResponse"];
|
onResponse?: SimpleStreamOptions["onResponse"];
|
||||||
@@ -207,24 +208,26 @@ export class Agent {
|
|||||||
public toolExecution: ToolExecutionMode;
|
public toolExecution: ToolExecutionMode;
|
||||||
|
|
||||||
constructor(options: AgentOptions) {
|
constructor(options: AgentOptions) {
|
||||||
this._state = createMutableAgentState(options.initialState);
|
// Older compiled consumers may omit options or streamFn even though the current API requires them.
|
||||||
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
|
const runtimeOptions: Partial<AgentOptions> = options ?? {};
|
||||||
this.transformContext = options.transformContext;
|
this._state = createMutableAgentState(runtimeOptions.initialState);
|
||||||
this.streamFunction = options.streamFunction;
|
this.convertToLlm = runtimeOptions.convertToLlm ?? defaultConvertToLlm;
|
||||||
this.getApiKey = options.getApiKey;
|
this.transformContext = runtimeOptions.transformContext;
|
||||||
this.onPayload = options.onPayload;
|
this.streamFunction = runtimeOptions.streamFn ?? getDefaultStreamFn();
|
||||||
this.onResponse = options.onResponse;
|
this.getApiKey = runtimeOptions.getApiKey;
|
||||||
this.beforeToolCall = options.beforeToolCall;
|
this.onPayload = runtimeOptions.onPayload;
|
||||||
this.afterToolCall = options.afterToolCall;
|
this.onResponse = runtimeOptions.onResponse;
|
||||||
this.prepareNextTurn = options.prepareNextTurn;
|
this.beforeToolCall = runtimeOptions.beforeToolCall;
|
||||||
this.prepareNextTurnWithContext = options.prepareNextTurnWithContext;
|
this.afterToolCall = runtimeOptions.afterToolCall;
|
||||||
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
|
this.prepareNextTurn = runtimeOptions.prepareNextTurn;
|
||||||
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
|
this.prepareNextTurnWithContext = runtimeOptions.prepareNextTurnWithContext;
|
||||||
this.sessionId = options.sessionId;
|
this.steeringQueue = new PendingMessageQueue(runtimeOptions.steeringMode ?? "one-at-a-time");
|
||||||
this.thinkingBudgets = options.thinkingBudgets;
|
this.followUpQueue = new PendingMessageQueue(runtimeOptions.followUpMode ?? "one-at-a-time");
|
||||||
this.transport = options.transport ?? "auto";
|
this.sessionId = runtimeOptions.sessionId;
|
||||||
this.maxRetryDelayMs = options.maxRetryDelayMs;
|
this.thinkingBudgets = runtimeOptions.thinkingBudgets;
|
||||||
this.toolExecution = options.toolExecution ?? "parallel";
|
this.transport = runtimeOptions.transport ?? "auto";
|
||||||
|
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
|
||||||
|
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
type ImageContent,
|
type ImageContent,
|
||||||
type Model,
|
type Model,
|
||||||
type Models,
|
type Models,
|
||||||
|
type RetryCallbacks,
|
||||||
|
type RetryPolicy,
|
||||||
type UserMessage,
|
type UserMessage,
|
||||||
} from "@earendil-works/pi-ai";
|
} from "@earendil-works/pi-ai";
|
||||||
import { runAgentLoop } from "../agent-loop.ts";
|
import { runAgentLoop } from "../agent-loop.ts";
|
||||||
@@ -182,6 +184,7 @@ export class AgentHarness<
|
|||||||
private systemPrompt: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>["systemPrompt"];
|
private systemPrompt: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>["systemPrompt"];
|
||||||
private toolContext: AgentHarnessToolContextSource<TContext> | undefined;
|
private toolContext: AgentHarnessToolContextSource<TContext> | undefined;
|
||||||
private streamOptions: AgentHarnessStreamOptions;
|
private streamOptions: AgentHarnessStreamOptions;
|
||||||
|
private retry: RetryPolicy | undefined;
|
||||||
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||||
private tools = new Map<string, TTool>();
|
private tools = new Map<string, TTool>();
|
||||||
private activeToolNames: string[];
|
private activeToolNames: string[];
|
||||||
@@ -197,6 +200,7 @@ export class AgentHarness<
|
|||||||
this.models = options.models;
|
this.models = options.models;
|
||||||
this.resources = options.resources ?? {};
|
this.resources = options.resources ?? {};
|
||||||
this.streamOptions = cloneStreamOptions(options.streamOptions);
|
this.streamOptions = cloneStreamOptions(options.streamOptions);
|
||||||
|
this.retry = options.retry;
|
||||||
this.systemPrompt = options.systemPrompt;
|
this.systemPrompt = options.systemPrompt;
|
||||||
this.toolContext = options.toolContext;
|
this.toolContext = options.toolContext;
|
||||||
this.validateUniqueNames(
|
this.validateUniqueNames(
|
||||||
@@ -260,6 +264,15 @@ export class AgentHarness<
|
|||||||
return lastResult;
|
return lastResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private retryCallbacks(operation: "compaction" | "branch_summary"): RetryCallbacks {
|
||||||
|
return {
|
||||||
|
onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) =>
|
||||||
|
this.emitOwn({ type: "retry_scheduled", operation, attempt, maxAttempts, delayMs, errorMessage }),
|
||||||
|
onRetryAttemptStart: () => this.emitOwn({ type: "retry_attempt_start", operation }),
|
||||||
|
onRetryFinished: () => this.emitOwn({ type: "retry_finished", operation }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async emitBeforeProviderRequest(
|
private async emitBeforeProviderRequest(
|
||||||
model: Model<any>,
|
model: Model<any>,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -741,7 +754,16 @@ export class AgentHarness<
|
|||||||
const provided = hookResult?.compaction;
|
const provided = hookResult?.compaction;
|
||||||
const compactResult = provided
|
const compactResult = provided
|
||||||
? { ok: true as const, value: provided }
|
? { ok: true as const, value: provided }
|
||||||
: await compact(preparation, this.models, model, customInstructions, undefined, this.thinkingLevel);
|
: await compact(
|
||||||
|
preparation,
|
||||||
|
this.models,
|
||||||
|
model,
|
||||||
|
customInstructions,
|
||||||
|
undefined,
|
||||||
|
this.thinkingLevel,
|
||||||
|
this.retry,
|
||||||
|
this.retryCallbacks("compaction"),
|
||||||
|
);
|
||||||
if (!compactResult.ok) throw compactResult.error;
|
if (!compactResult.ok) throw compactResult.error;
|
||||||
const result = compactResult.value;
|
const result = compactResult.value;
|
||||||
const entryId = await this.session.appendCompaction(
|
const entryId = await this.session.appendCompaction(
|
||||||
@@ -803,6 +825,8 @@ export class AgentHarness<
|
|||||||
signal: new AbortController().signal,
|
signal: new AbortController().signal,
|
||||||
customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
|
customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
|
||||||
replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
|
replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
|
||||||
|
retry: this.retry,
|
||||||
|
callbacks: this.retryCallbacks("branch_summary"),
|
||||||
});
|
});
|
||||||
if (!branchSummary.ok) {
|
if (!branchSummary.ok) {
|
||||||
if (branchSummary.error.code === "aborted") return { cancelled: true };
|
if (branchSummary.error.code === "aborted") return { cancelled: true };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { contentText, type Model, type Models } from "@earendil-works/pi-ai";
|
import { contentText, type Model, type Models, type RetryCallbacks, type RetryPolicy } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
import type { AgentMessage } from "../../types.ts";
|
import type { AgentMessage } from "../../types.ts";
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "../messages.ts";
|
} from "../messages.ts";
|
||||||
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts";
|
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.ts";
|
||||||
import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts";
|
import { BranchSummaryError, err, ok, type Result, SessionError } from "../types.ts";
|
||||||
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
|
import { completeSimpleWithRetries, estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.ts";
|
||||||
import {
|
import {
|
||||||
computeFileLists,
|
computeFileLists,
|
||||||
createFileOps,
|
createFileOps,
|
||||||
@@ -61,6 +61,10 @@ export interface GenerateBranchSummaryOptions {
|
|||||||
replaceInstructions?: boolean;
|
replaceInstructions?: boolean;
|
||||||
/** Tokens reserved for prompt and model output. Defaults to 16384. */
|
/** Tokens reserved for prompt and model output. Defaults to 16384. */
|
||||||
reserveTokens?: number;
|
reserveTokens?: number;
|
||||||
|
/** Optional retry policy for transient summarization errors. */
|
||||||
|
retry?: RetryPolicy;
|
||||||
|
/** Optional callbacks for retry reporting. */
|
||||||
|
callbacks?: RetryCallbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect entries that should be summarized before navigating to a different session tree entry. */
|
/** Collect entries that should be summarized before navigating to a different session tree entry. */
|
||||||
@@ -200,7 +204,16 @@ export async function generateBranchSummary(
|
|||||||
entries: SessionTreeEntry[],
|
entries: SessionTreeEntry[],
|
||||||
options: GenerateBranchSummaryOptions,
|
options: GenerateBranchSummaryOptions,
|
||||||
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
|
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
|
||||||
const { models, model, signal, customInstructions, replaceInstructions, reserveTokens = 16384 } = options;
|
const {
|
||||||
|
models,
|
||||||
|
model,
|
||||||
|
signal,
|
||||||
|
customInstructions,
|
||||||
|
replaceInstructions,
|
||||||
|
reserveTokens = 16384,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
|
} = options;
|
||||||
const contextWindow = model.contextWindow || 128000;
|
const contextWindow = model.contextWindow || 128000;
|
||||||
const tokenBudget = contextWindow - reserveTokens;
|
const tokenBudget = contextWindow - reserveTokens;
|
||||||
|
|
||||||
@@ -228,10 +241,13 @@ export async function generateBranchSummary(
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const response = await models.completeSimple(
|
const response = await completeSimpleWithRetries(
|
||||||
|
models,
|
||||||
model,
|
model,
|
||||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||||
{ signal, maxTokens: 2048 },
|
{ signal, maxTokens: 2048 },
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
if (response.stopReason === "aborted") {
|
if (response.stopReason === "aborted") {
|
||||||
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
|
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
type AssistantMessage,
|
type AssistantMessage,
|
||||||
|
type Context,
|
||||||
contentText,
|
contentText,
|
||||||
type ImageContent,
|
type ImageContent,
|
||||||
type Model,
|
type Model,
|
||||||
type Models,
|
type Models,
|
||||||
|
type RetryCallbacks,
|
||||||
|
type RetryPolicy,
|
||||||
|
retryAssistantCall,
|
||||||
|
type SimpleStreamOptions,
|
||||||
type TextContent,
|
type TextContent,
|
||||||
type Usage,
|
type Usage,
|
||||||
} from "@earendil-works/pi-ai";
|
} from "@earendil-works/pi-ai";
|
||||||
@@ -109,6 +114,17 @@ export interface CompactionResult<T = unknown> {
|
|||||||
details?: T;
|
details?: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function completeSimpleWithRetries(
|
||||||
|
models: Models,
|
||||||
|
model: Model<any>,
|
||||||
|
context: Context,
|
||||||
|
options: SimpleStreamOptions,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
|
): Promise<AssistantMessage> {
|
||||||
|
return retryAssistantCall(() => models.completeSimple(model, context, options), retry, options.signal, callbacks);
|
||||||
|
}
|
||||||
|
|
||||||
function combineUsage(first: Usage, second: Usage): Usage {
|
function combineUsage(first: Usage, second: Usage): Usage {
|
||||||
return {
|
return {
|
||||||
input: first.input + second.input,
|
input: first.input + second.input,
|
||||||
@@ -501,6 +517,8 @@ export async function generateSummary(
|
|||||||
customInstructions?: string,
|
customInstructions?: string,
|
||||||
previousSummary?: string,
|
previousSummary?: string,
|
||||||
thinkingLevel?: ThinkingLevel,
|
thinkingLevel?: ThinkingLevel,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<Result<string, CompactionError>> {
|
): Promise<Result<string, CompactionError>> {
|
||||||
const result = await generateSummaryWithUsage(
|
const result = await generateSummaryWithUsage(
|
||||||
currentMessages,
|
currentMessages,
|
||||||
@@ -511,6 +529,8 @@ export async function generateSummary(
|
|||||||
customInstructions,
|
customInstructions,
|
||||||
previousSummary,
|
previousSummary,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
return result.ok ? ok(result.value.text) : err(result.error);
|
return result.ok ? ok(result.value.text) : err(result.error);
|
||||||
}
|
}
|
||||||
@@ -525,6 +545,8 @@ export async function generateSummaryWithUsage(
|
|||||||
customInstructions?: string,
|
customInstructions?: string,
|
||||||
previousSummary?: string,
|
previousSummary?: string,
|
||||||
thinkingLevel?: ThinkingLevel,
|
thinkingLevel?: ThinkingLevel,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||||
const maxTokens = Math.min(
|
const maxTokens = Math.min(
|
||||||
Math.floor(0.8 * reserveTokens),
|
Math.floor(0.8 * reserveTokens),
|
||||||
@@ -555,10 +577,13 @@ export async function generateSummaryWithUsage(
|
|||||||
? { maxTokens, signal, reasoning: thinkingLevel }
|
? { maxTokens, signal, reasoning: thinkingLevel }
|
||||||
: { maxTokens, signal };
|
: { maxTokens, signal };
|
||||||
|
|
||||||
const response = await models.completeSimple(
|
const response = await completeSimpleWithRetries(
|
||||||
|
models,
|
||||||
model,
|
model,
|
||||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||||
completionOptions,
|
completionOptions,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
if (response.stopReason === "aborted") {
|
if (response.stopReason === "aborted") {
|
||||||
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
|
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
|
||||||
@@ -700,6 +725,8 @@ export async function compact(
|
|||||||
customInstructions?: string,
|
customInstructions?: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
thinkingLevel?: ThinkingLevel,
|
thinkingLevel?: ThinkingLevel,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<Result<CompactionResult, CompactionError>> {
|
): Promise<Result<CompactionResult, CompactionError>> {
|
||||||
const {
|
const {
|
||||||
firstKeptEntryId,
|
firstKeptEntryId,
|
||||||
@@ -733,6 +760,8 @@ export async function compact(
|
|||||||
customInstructions,
|
customInstructions,
|
||||||
previousSummary,
|
previousSummary,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
if (!historyResult.ok) return err(historyResult.error);
|
if (!historyResult.ok) return err(historyResult.error);
|
||||||
historyText = historyResult.value.text;
|
historyText = historyResult.value.text;
|
||||||
@@ -745,6 +774,8 @@ export async function compact(
|
|||||||
settings.reserveTokens,
|
settings.reserveTokens,
|
||||||
signal,
|
signal,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
|
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
|
||||||
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`;
|
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`;
|
||||||
@@ -761,6 +792,8 @@ export async function compact(
|
|||||||
customInstructions,
|
customInstructions,
|
||||||
previousSummary,
|
previousSummary,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
if (!summaryResult.ok) return err(summaryResult.error);
|
if (!summaryResult.ok) return err(summaryResult.error);
|
||||||
summary = summaryResult.value.text;
|
summary = summaryResult.value.text;
|
||||||
@@ -786,6 +819,8 @@ async function generateTurnPrefixSummary(
|
|||||||
reserveTokens: number,
|
reserveTokens: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
thinkingLevel?: ThinkingLevel,
|
thinkingLevel?: ThinkingLevel,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||||
const maxTokens = Math.min(
|
const maxTokens = Math.min(
|
||||||
Math.floor(0.5 * reserveTokens),
|
Math.floor(0.5 * reserveTokens),
|
||||||
@@ -802,12 +837,17 @@ async function generateTurnPrefixSummary(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const response = await models.completeSimple(
|
const completionOptions =
|
||||||
model,
|
|
||||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
|
||||||
model.reasoning && thinkingLevel && thinkingLevel !== "off"
|
model.reasoning && thinkingLevel && thinkingLevel !== "off"
|
||||||
? { maxTokens, signal, reasoning: thinkingLevel }
|
? { maxTokens, signal, reasoning: thinkingLevel }
|
||||||
: { maxTokens, signal },
|
: { maxTokens, signal };
|
||||||
|
const response = await completeSimpleWithRetries(
|
||||||
|
models,
|
||||||
|
model,
|
||||||
|
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||||
|
completionOptions,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
if (response.stopReason === "aborted") {
|
if (response.stopReason === "aborted") {
|
||||||
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
|
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
ImageContent,
|
ImageContent,
|
||||||
Model,
|
Model,
|
||||||
Models,
|
Models,
|
||||||
|
RetryPolicy,
|
||||||
SimpleStreamOptions,
|
SimpleStreamOptions,
|
||||||
TextContent,
|
TextContent,
|
||||||
Transport,
|
Transport,
|
||||||
@@ -659,6 +660,25 @@ export interface SessionTreeEvent {
|
|||||||
fromHook?: boolean;
|
fromHook?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RetryScheduledEvent {
|
||||||
|
type: "retry_scheduled";
|
||||||
|
operation: "compaction" | "branch_summary";
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
delayMs: number;
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryAttemptStartEvent {
|
||||||
|
type: "retry_attempt_start";
|
||||||
|
operation: "compaction" | "branch_summary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetryFinishedEvent {
|
||||||
|
type: "retry_finished";
|
||||||
|
operation: "compaction" | "branch_summary";
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelUpdateEvent {
|
export interface ModelUpdateEvent {
|
||||||
type: "model_update";
|
type: "model_update";
|
||||||
model: Model<any>;
|
model: Model<any>;
|
||||||
@@ -709,6 +729,9 @@ export type AgentHarnessOwnEvent<
|
|||||||
| SessionCompactEvent
|
| SessionCompactEvent
|
||||||
| SessionBeforeTreeEvent
|
| SessionBeforeTreeEvent
|
||||||
| SessionTreeEvent
|
| SessionTreeEvent
|
||||||
|
| RetryScheduledEvent
|
||||||
|
| RetryAttemptStartEvent
|
||||||
|
| RetryFinishedEvent
|
||||||
| ModelUpdateEvent
|
| ModelUpdateEvent
|
||||||
| ThinkingLevelUpdateEvent
|
| ThinkingLevelUpdateEvent
|
||||||
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
|
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
|
||||||
@@ -778,6 +801,9 @@ export type AgentHarnessEventResultMap = {
|
|||||||
session_compact: undefined;
|
session_compact: undefined;
|
||||||
session_before_tree: SessionBeforeTreeResult | undefined;
|
session_before_tree: SessionBeforeTreeResult | undefined;
|
||||||
session_tree: undefined;
|
session_tree: undefined;
|
||||||
|
retry_scheduled: undefined;
|
||||||
|
retry_attempt_start: undefined;
|
||||||
|
retry_finished: undefined;
|
||||||
model_update: undefined;
|
model_update: undefined;
|
||||||
thinking_level_update: undefined;
|
thinking_level_update: undefined;
|
||||||
resources_update: undefined;
|
resources_update: undefined;
|
||||||
@@ -897,6 +923,8 @@ export interface AgentHarnessOptions<
|
|||||||
}) => string | Promise<string>);
|
}) => string | Promise<string>);
|
||||||
/** Curated stream/provider request options. Snapshotted at turn start. */
|
/** Curated stream/provider request options. Snapshotted at turn start. */
|
||||||
streamOptions?: AgentHarnessStreamOptions;
|
streamOptions?: AgentHarnessStreamOptions;
|
||||||
|
/** Optional retry policy for generated compaction and branch-summary requests. */
|
||||||
|
retry?: RetryPolicy;
|
||||||
model: Model<any>;
|
model: Model<any>;
|
||||||
thinkingLevel?: ThinkingLevel;
|
thinkingLevel?: ThinkingLevel;
|
||||||
activeToolNames?: string[];
|
activeToolNames?: string[];
|
||||||
|
|||||||
@@ -44,5 +44,7 @@ export * from "./harness/utils/shell-output.ts";
|
|||||||
export * from "./harness/utils/truncate.ts";
|
export * from "./harness/utils/truncate.ts";
|
||||||
// Proxy utilities
|
// Proxy utilities
|
||||||
export * from "./proxy.ts";
|
export * from "./proxy.ts";
|
||||||
|
// Stream defaults
|
||||||
|
export { setDefaultStreamFn } from "./stream-fn.ts";
|
||||||
// Types
|
// Types
|
||||||
export * from "./types.ts";
|
export * from "./types.ts";
|
||||||
|
|||||||
@@ -84,12 +84,12 @@ export interface ProxyStreamOptions extends ProxySerializableStreamOptions {
|
|||||||
* The server strips the partial field from delta events to reduce bandwidth.
|
* The server strips the partial field from delta events to reduce bandwidth.
|
||||||
* We reconstruct the partial message client-side.
|
* We reconstruct the partial message client-side.
|
||||||
*
|
*
|
||||||
* Use this as the `streamFunction` option when creating an Agent that needs to go through a proxy.
|
* Use this as the `streamFn` option when creating an Agent that needs to go through a proxy.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* const agent = new Agent({
|
* const agent = new Agent({
|
||||||
* streamFunction: (model, context, options) =>
|
* streamFn: (model, context, options) =>
|
||||||
* streamProxy(model, context, {
|
* streamProxy(model, context, {
|
||||||
* ...options,
|
* ...options,
|
||||||
* authToken: await getAuthToken(),
|
* authToken: await getAuthToken(),
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { StreamFn } from "./types.ts";
|
||||||
|
|
||||||
|
let defaultStreamFn: StreamFn | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the fallback used by Agent and low-level loops when callers omit streamFn.
|
||||||
|
*
|
||||||
|
* Hosts that provide a default model runtime can install its stream function here
|
||||||
|
* without making pi-agent-core depend on a provider catalog or compatibility layer.
|
||||||
|
*/
|
||||||
|
export function setDefaultStreamFn(streamFn: StreamFn | undefined): void {
|
||||||
|
defaultStreamFn = streamFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultStreamFn(): StreamFn {
|
||||||
|
if (!defaultStreamFn) {
|
||||||
|
throw new Error("No default stream function configured. Pass streamFn explicitly or call setDefaultStreamFn().");
|
||||||
|
}
|
||||||
|
return defaultStreamFn;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts";
|
import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts";
|
||||||
|
import { setDefaultStreamFn } from "../src/index.ts";
|
||||||
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";
|
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";
|
||||||
|
|
||||||
// Mock stream for testing - mimics MockAssistantStream
|
// Mock stream for testing - mimics MockAssistantStream
|
||||||
@@ -80,6 +81,40 @@ function identityConverter(messages: AgentMessage[]): Message[] {
|
|||||||
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
|
return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("default stream function compatibility", () => {
|
||||||
|
it("uses the configured default when a legacy caller omits streamFn", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
setDefaultStreamFn(() => {
|
||||||
|
calls++;
|
||||||
|
const stream = new MockAssistantStream();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
stream.push({
|
||||||
|
type: "done",
|
||||||
|
reason: "stop",
|
||||||
|
message: createAssistantMessage([{ type: "text", text: "fallback" }]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return stream;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context: AgentContext = { systemPrompt: "", messages: [], tools: [] };
|
||||||
|
const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter };
|
||||||
|
const stream = Reflect.apply(agentLoop, undefined, [
|
||||||
|
[createUserMessage("Hello")],
|
||||||
|
context,
|
||||||
|
config,
|
||||||
|
undefined,
|
||||||
|
]) as ReturnType<typeof agentLoop>;
|
||||||
|
|
||||||
|
await stream.result();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
} finally {
|
||||||
|
setDefaultStreamFn(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("agentLoop with AgentMessage", () => {
|
describe("agentLoop with AgentMessage", () => {
|
||||||
it("should emit events with AgentMessage types", async () => {
|
it("should emit events with AgentMessage types", async () => {
|
||||||
const context: AgentContext = {
|
const context: AgentContext = {
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
|
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback, type StreamFn } from "../src/index.ts";
|
import {
|
||||||
|
Agent,
|
||||||
|
type AgentEvent,
|
||||||
|
type AgentTool,
|
||||||
|
type AgentToolUpdateCallback,
|
||||||
|
type StreamFn,
|
||||||
|
setDefaultStreamFn,
|
||||||
|
} from "../src/index.ts";
|
||||||
|
|
||||||
// Mock stream that mimics AssistantMessageEventStream
|
// Mock stream that mimics AssistantMessageEventStream
|
||||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||||
@@ -75,8 +82,29 @@ function createDeferred(): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Agent", () => {
|
describe("Agent", () => {
|
||||||
|
it("uses the configured default when a legacy caller omits streamFn", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
setDefaultStreamFn(() => {
|
||||||
|
calls++;
|
||||||
|
const stream = new MockAssistantStream();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const message = createAssistantMessage("fallback");
|
||||||
|
stream.push({ type: "done", reason: "stop", message });
|
||||||
|
});
|
||||||
|
return stream;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agent = Reflect.construct(Agent, [{}]) as Agent;
|
||||||
|
await agent.prompt("Hello");
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
} finally {
|
||||||
|
setDefaultStreamFn(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("should create an agent instance with default state", () => {
|
it("should create an agent instance with default state", () => {
|
||||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||||
|
|
||||||
expect(agent.state).toBeDefined();
|
expect(agent.state).toBeDefined();
|
||||||
expect(agent.state.systemPrompt).toBe("");
|
expect(agent.state.systemPrompt).toBe("");
|
||||||
@@ -93,7 +121,7 @@ describe("Agent", () => {
|
|||||||
it("should create an agent instance with custom initial state", () => {
|
it("should create an agent instance with custom initial state", () => {
|
||||||
const customModel = getModel("openai", "gpt-4o-mini");
|
const customModel = getModel("openai", "gpt-4o-mini");
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: unusedStreamFunction,
|
streamFn: unusedStreamFunction,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
model: customModel,
|
model: customModel,
|
||||||
@@ -107,7 +135,7 @@ describe("Agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should subscribe to events", () => {
|
it("should subscribe to events", () => {
|
||||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||||
|
|
||||||
let eventCount = 0;
|
let eventCount = 0;
|
||||||
const unsubscribe = agent.subscribe((_event) => {
|
const unsubscribe = agent.subscribe((_event) => {
|
||||||
@@ -130,7 +158,7 @@ describe("Agent", () => {
|
|||||||
|
|
||||||
it("emits full lifecycle events for thrown run failures", async () => {
|
it("emits full lifecycle events for thrown run failures", async () => {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
throw new Error("provider exploded");
|
throw new Error("provider exploded");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -162,7 +190,7 @@ describe("Agent", () => {
|
|||||||
it("should await async subscribers before prompt resolves", async () => {
|
it("should await async subscribers before prompt resolves", async () => {
|
||||||
const barrier = createDeferred();
|
const barrier = createDeferred();
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
||||||
@@ -200,7 +228,7 @@ describe("Agent", () => {
|
|||||||
it("waitForIdle should wait for async subscribers", async () => {
|
it("waitForIdle should wait for async subscribers", async () => {
|
||||||
const barrier = createDeferred();
|
const barrier = createDeferred();
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
||||||
@@ -235,7 +263,7 @@ describe("Agent", () => {
|
|||||||
it("should pass the active abort signal to subscribers", async () => {
|
it("should pass the active abort signal to subscribers", async () => {
|
||||||
let receivedSignal: AbortSignal | undefined;
|
let receivedSignal: AbortSignal | undefined;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: (_model, _context, options) => {
|
streamFn: (_model, _context, options) => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||||
@@ -298,7 +326,7 @@ describe("Agent", () => {
|
|||||||
};
|
};
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: { tools: [tool] },
|
initialState: { tools: [tool] },
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({
|
stream.push({
|
||||||
@@ -373,7 +401,7 @@ describe("Agent", () => {
|
|||||||
};
|
};
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: { tools: [settledTool, slowTool] },
|
initialState: { tools: [settledTool, slowTool] },
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({
|
stream.push({
|
||||||
@@ -412,7 +440,7 @@ describe("Agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should update state with mutators", () => {
|
it("should update state with mutators", () => {
|
||||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||||
|
|
||||||
// Test setSystemPrompt
|
// Test setSystemPrompt
|
||||||
agent.state.systemPrompt = "Custom prompt";
|
agent.state.systemPrompt = "Custom prompt";
|
||||||
@@ -451,7 +479,7 @@ describe("Agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should support steering message queue", async () => {
|
it("should support steering message queue", async () => {
|
||||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||||
|
|
||||||
const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() };
|
const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() };
|
||||||
agent.steer(message);
|
agent.steer(message);
|
||||||
@@ -461,7 +489,7 @@ describe("Agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should support follow-up message queue", async () => {
|
it("should support follow-up message queue", async () => {
|
||||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||||
|
|
||||||
const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() };
|
const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() };
|
||||||
agent.followUp(message);
|
agent.followUp(message);
|
||||||
@@ -471,7 +499,7 @@ describe("Agent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should handle abort controller", () => {
|
it("should handle abort controller", () => {
|
||||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||||
|
|
||||||
// Should not throw even if nothing is running
|
// Should not throw even if nothing is running
|
||||||
expect(() => agent.abort()).not.toThrow();
|
expect(() => agent.abort()).not.toThrow();
|
||||||
@@ -481,7 +509,7 @@ describe("Agent", () => {
|
|||||||
let abortSignal: AbortSignal | undefined;
|
let abortSignal: AbortSignal | undefined;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
// Use a stream function that responds to abort
|
// Use a stream function that responds to abort
|
||||||
streamFunction: (_model, _context, options) => {
|
streamFn: (_model, _context, options) => {
|
||||||
abortSignal = options?.signal;
|
abortSignal = options?.signal;
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -520,7 +548,7 @@ describe("Agent", () => {
|
|||||||
it("should throw when continue() called while streaming", async () => {
|
it("should throw when continue() called while streaming", async () => {
|
||||||
let abortSignal: AbortSignal | undefined;
|
let abortSignal: AbortSignal | undefined;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: (_model, _context, options) => {
|
streamFn: (_model, _context, options) => {
|
||||||
abortSignal = options?.signal;
|
abortSignal = options?.signal;
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -555,7 +583,7 @@ describe("Agent", () => {
|
|||||||
|
|
||||||
it("continue() should process queued follow-up messages after an assistant turn", async () => {
|
it("continue() should process queued follow-up messages after an assistant turn", async () => {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
|
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") });
|
||||||
@@ -594,7 +622,7 @@ describe("Agent", () => {
|
|||||||
it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => {
|
it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => {
|
||||||
let responseCount = 0;
|
let responseCount = 0;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
responseCount++;
|
responseCount++;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -652,7 +680,7 @@ describe("Agent", () => {
|
|||||||
sawAbortSignal = signal instanceof AbortSignal;
|
sawAbortSignal = signal instanceof AbortSignal;
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
requestCount++;
|
requestCount++;
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -680,7 +708,7 @@ describe("Agent", () => {
|
|||||||
let receivedSessionId: string | undefined;
|
let receivedSessionId: string | undefined;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
sessionId: "session-abc",
|
sessionId: "session-abc",
|
||||||
streamFunction: (_model, _context, options) => {
|
streamFn: (_model, _context, options) => {
|
||||||
receivedSessionId = options?.sessionId;
|
receivedSessionId = options?.sessionId;
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
async function basicPrompt(model: Model<string>) {
|
async function basicPrompt(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant. Keep your responses concise.",
|
systemPrompt: "You are a helpful assistant. Keep your responses concise.",
|
||||||
model,
|
model,
|
||||||
@@ -61,7 +61,7 @@ async function basicPrompt(model: Model<string>) {
|
|||||||
|
|
||||||
async function toolExecution(model: Model<string>) {
|
async function toolExecution(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.",
|
systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.",
|
||||||
model,
|
model,
|
||||||
@@ -101,7 +101,7 @@ async function toolExecution(model: Model<string>) {
|
|||||||
|
|
||||||
async function abortExecution(model: Model<string>) {
|
async function abortExecution(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
model,
|
model,
|
||||||
@@ -129,7 +129,7 @@ async function abortExecution(model: Model<string>) {
|
|||||||
|
|
||||||
async function stateUpdates(model: Model<string>) {
|
async function stateUpdates(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
model,
|
model,
|
||||||
@@ -162,7 +162,7 @@ async function stateUpdates(model: Model<string>) {
|
|||||||
|
|
||||||
async function multiTurnConversation(model: Model<string>) {
|
async function multiTurnConversation(model: Model<string>) {
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
model,
|
model,
|
||||||
@@ -244,7 +244,7 @@ describe("Agent integration with faux provider", () => {
|
|||||||
faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]);
|
faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]);
|
||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
model: faux.getModel(),
|
model: faux.getModel(),
|
||||||
@@ -269,7 +269,7 @@ describe("Agent.continue() with faux provider", () => {
|
|||||||
it("throws when no messages in context", async () => {
|
it("throws when no messages in context", async () => {
|
||||||
const faux = createFauxRegistration();
|
const faux = createFauxRegistration();
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
model: faux.getModel(),
|
model: faux.getModel(),
|
||||||
@@ -283,7 +283,7 @@ describe("Agent.continue() with faux provider", () => {
|
|||||||
const faux = createFauxRegistration();
|
const faux = createFauxRegistration();
|
||||||
const model = faux.getModel();
|
const model = faux.getModel();
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
model,
|
model,
|
||||||
@@ -318,7 +318,7 @@ describe("Agent.continue() with faux provider", () => {
|
|||||||
const faux = createFauxRegistration();
|
const faux = createFauxRegistration();
|
||||||
faux.setResponses([fauxAssistantMessage("HELLO WORLD")]);
|
faux.setResponses([fauxAssistantMessage("HELLO WORLD")]);
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
|
systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
|
||||||
model: faux.getModel(),
|
model: faux.getModel(),
|
||||||
@@ -353,7 +353,7 @@ describe("Agent.continue() with faux provider", () => {
|
|||||||
const model = faux.getModel();
|
const model = faux.getModel();
|
||||||
faux.setResponses([fauxAssistantMessage("The answer is 8.")]);
|
faux.setResponses([fauxAssistantMessage("The answer is 8.")]);
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
"You are a helpful assistant. After getting a calculation result, state the answer clearly.",
|
"You are a helpful assistant. After getting a calculation result, state the answer clearly.",
|
||||||
|
|||||||
@@ -605,6 +605,176 @@ describe("AgentHarness", () => {
|
|||||||
expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage);
|
expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("summarization retries", () => {
|
||||||
|
it("retries transient compaction errors and emits retry events", async () => {
|
||||||
|
const registration = newFaux();
|
||||||
|
let calls = 0;
|
||||||
|
registration.setResponses([
|
||||||
|
() => {
|
||||||
|
calls++;
|
||||||
|
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
calls++;
|
||||||
|
return fauxAssistantMessage("## Goal\nRecovered summary");
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = new Session(new InMemorySessionStorage());
|
||||||
|
await session.appendMessage(createUserMessage("one"));
|
||||||
|
await session.appendMessage(createAssistantMessage("two"));
|
||||||
|
const harness = new AgentHarness({
|
||||||
|
models,
|
||||||
|
session,
|
||||||
|
model: registration.getModel(),
|
||||||
|
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
|
||||||
|
});
|
||||||
|
const retryEvents: string[] = [];
|
||||||
|
harness.subscribe((event) => {
|
||||||
|
if (
|
||||||
|
event.type === "retry_scheduled" ||
|
||||||
|
event.type === "retry_attempt_start" ||
|
||||||
|
event.type === "retry_finished"
|
||||||
|
) {
|
||||||
|
retryEvents.push(`${event.type}:${event.operation}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await harness.compact();
|
||||||
|
|
||||||
|
expect(result.summary).toContain("Recovered summary");
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
expect(retryEvents).toEqual([
|
||||||
|
"retry_scheduled:compaction",
|
||||||
|
"retry_attempt_start:compaction",
|
||||||
|
"retry_finished:compaction",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry non-retryable compaction errors", async () => {
|
||||||
|
const registration = newFaux();
|
||||||
|
let calls = 0;
|
||||||
|
registration.setResponses([
|
||||||
|
() => {
|
||||||
|
calls++;
|
||||||
|
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" });
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = new Session(new InMemorySessionStorage());
|
||||||
|
await session.appendMessage(createUserMessage("one"));
|
||||||
|
await session.appendMessage(createAssistantMessage("two"));
|
||||||
|
const harness = new AgentHarness({
|
||||||
|
models,
|
||||||
|
session,
|
||||||
|
model: registration.getModel(),
|
||||||
|
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
|
||||||
|
});
|
||||||
|
const retryEvents: string[] = [];
|
||||||
|
harness.subscribe((event) => {
|
||||||
|
if (
|
||||||
|
event.type === "retry_scheduled" ||
|
||||||
|
event.type === "retry_attempt_start" ||
|
||||||
|
event.type === "retry_finished"
|
||||||
|
) {
|
||||||
|
retryEvents.push(event.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(harness.compact()).rejects.toThrow("insufficient_quota");
|
||||||
|
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(retryEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exhausts transient compaction retries after maxRetries failures", async () => {
|
||||||
|
const registration = newFaux();
|
||||||
|
let calls = 0;
|
||||||
|
registration.setResponses(
|
||||||
|
Array.from({ length: 4 }, () => () => {
|
||||||
|
calls++;
|
||||||
|
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const session = new Session(new InMemorySessionStorage());
|
||||||
|
await session.appendMessage(createUserMessage("one"));
|
||||||
|
await session.appendMessage(createAssistantMessage("two"));
|
||||||
|
const harness = new AgentHarness({
|
||||||
|
models,
|
||||||
|
session,
|
||||||
|
model: registration.getModel(),
|
||||||
|
retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 },
|
||||||
|
});
|
||||||
|
const retryEvents: string[] = [];
|
||||||
|
harness.subscribe((event) => {
|
||||||
|
if (
|
||||||
|
event.type === "retry_scheduled" ||
|
||||||
|
event.type === "retry_attempt_start" ||
|
||||||
|
event.type === "retry_finished"
|
||||||
|
) {
|
||||||
|
retryEvents.push(`${event.type}:${event.operation}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(harness.compact()).rejects.toThrow("terminated");
|
||||||
|
|
||||||
|
expect(calls).toBe(4);
|
||||||
|
expect(retryEvents).toEqual([
|
||||||
|
"retry_scheduled:compaction",
|
||||||
|
"retry_attempt_start:compaction",
|
||||||
|
"retry_scheduled:compaction",
|
||||||
|
"retry_attempt_start:compaction",
|
||||||
|
"retry_scheduled:compaction",
|
||||||
|
"retry_attempt_start:compaction",
|
||||||
|
"retry_finished:compaction",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries transient branch summary errors and emits retry events", async () => {
|
||||||
|
const registration = newFaux();
|
||||||
|
let calls = 0;
|
||||||
|
registration.setResponses([
|
||||||
|
() => {
|
||||||
|
calls++;
|
||||||
|
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" });
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
calls++;
|
||||||
|
return fauxAssistantMessage("## Goal\nRecovered branch summary");
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const session = new Session(new InMemorySessionStorage());
|
||||||
|
const targetId = await session.appendMessage(createUserMessage("first branch"));
|
||||||
|
await session.appendMessage(createAssistantMessage("first reply"));
|
||||||
|
await session.appendMessage(createUserMessage("abandoned work"));
|
||||||
|
await session.appendMessage(createAssistantMessage("abandoned reply"));
|
||||||
|
const harness = new AgentHarness({
|
||||||
|
models,
|
||||||
|
session,
|
||||||
|
model: registration.getModel(),
|
||||||
|
retry: { enabled: true, maxRetries: 1, baseDelayMs: 0 },
|
||||||
|
});
|
||||||
|
const retryEvents: string[] = [];
|
||||||
|
harness.subscribe((event) => {
|
||||||
|
if (
|
||||||
|
event.type === "retry_scheduled" ||
|
||||||
|
event.type === "retry_attempt_start" ||
|
||||||
|
event.type === "retry_finished"
|
||||||
|
) {
|
||||||
|
retryEvents.push(`${event.type}:${event.operation}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await harness.navigateTree(targetId, { summarize: true });
|
||||||
|
|
||||||
|
expect(result.summaryEntry?.summary).toContain("Recovered branch summary");
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
expect(retryEvents).toEqual([
|
||||||
|
"retry_scheduled:branch_summary",
|
||||||
|
"retry_attempt_start:branch_summary",
|
||||||
|
"retry_finished:branch_summary",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("persists generated branch summary usage", async () => {
|
it("persists generated branch summary usage", async () => {
|
||||||
const registration = newFaux();
|
const registration = newFaux();
|
||||||
registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]);
|
registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]);
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed 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)).
|
||||||
|
|
||||||
|
## [0.81.1] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `retryAssistantCall()` for bounded retries of transient assistant failures with lifecycle callbacks and abort handling ([#6901](https://github.com/earendil-works/pi/pull/6901) by [@davidbrai](https://github.com/davidbrai)).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed Kimi K3 models from Moonshot AI and Moonshot AI China to use the OpenAI thinking format and expose reasoning effort support.
|
||||||
|
|
||||||
## [0.81.0] - 2026-07-21
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -10,6 +24,7 @@
|
|||||||
- Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)).
|
- Added `contentText` for extracting joined text from message content ([#6840](https://github.com/earendil-works/pi/pull/6840) by [@xl0](https://github.com/xl0)).
|
||||||
- Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
|
- Added a shared `uuidv7` utility for time-ordered identifiers ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)).
|
||||||
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
|
- Added optional usage metadata to tool result messages ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)).
|
||||||
|
- Added Kimi Code subscription OAuth login (device authorization grant) for the `kimi-coding` provider, with token refresh and `KIMI_CODE_OAUTH_HOST`/`KIMI_OAUTH_HOST` host overrides.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-ai",
|
"name": "@earendil-works/pi-ai",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
|
||||||
import { dirname, join, resolve } from "path";
|
import { dirname, join, resolve } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
|
||||||
import {
|
import {
|
||||||
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
|
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
|
||||||
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
|
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
|
||||||
@@ -83,6 +84,7 @@ interface ModelsDevModel {
|
|||||||
name: string;
|
name: string;
|
||||||
tool_call?: boolean;
|
tool_call?: boolean;
|
||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
|
reasoning_options?: ModelsDevReasoningOption[];
|
||||||
limit?: {
|
limit?: {
|
||||||
context?: number;
|
context?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
@@ -112,6 +114,12 @@ interface ModelsDevModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModelsDevProvider {
|
||||||
|
models?: Record<string, ModelsDevModel>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelsDevCatalog = Record<string, ModelsDevProvider>;
|
||||||
|
|
||||||
interface NvidiaNimModelListItem {
|
interface NvidiaNimModelListItem {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@@ -257,15 +265,6 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = {
|
|||||||
max: "max",
|
max: "max",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const KIMI_K3_THINKING_LEVEL_MAP = {
|
|
||||||
off: null,
|
|
||||||
minimal: null,
|
|
||||||
low: "low",
|
|
||||||
medium: null,
|
|
||||||
high: "high",
|
|
||||||
xhigh: null,
|
|
||||||
max: "max",
|
|
||||||
} as const;
|
|
||||||
const KIMI_K3_MAX_TOKENS = 131072;
|
const KIMI_K3_MAX_TOKENS = 131072;
|
||||||
const KIMI_K3_COST = {
|
const KIMI_K3_COST = {
|
||||||
input: 3,
|
input: 3,
|
||||||
@@ -398,6 +397,43 @@ function mergeThinkingLevelMap(model: Model<any>, map: NonNullable<Model<any>["t
|
|||||||
model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map };
|
model.thinkingLevelMap = { ...model.thinkingLevelMap, ...map };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const modelsDevReasoningOptions = new Map<string, ModelsDevReasoningOption[]>();
|
||||||
|
|
||||||
|
function getModelKey(model: Pick<Model<Api>, "provider" | "id">): string {
|
||||||
|
return `${model.provider}:${model.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordModelsDevReasoningOptions(provider: string, id: string, sourceModel: ModelsDevModel): void {
|
||||||
|
if (sourceModel.reasoning_options !== undefined) {
|
||||||
|
modelsDevReasoningOptions.set(`${provider}:${id}`, sourceModel.reasoning_options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportsDirectReasoningEffort(model: Model<Api>): boolean {
|
||||||
|
if (model.api === "anthropic-messages") return model.compat?.forceAdaptiveThinking === true;
|
||||||
|
if (
|
||||||
|
model.api === "openai-responses" ||
|
||||||
|
model.api === "azure-openai-responses" ||
|
||||||
|
model.api === "openai-codex-responses"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (model.api !== "openai-completions") return false;
|
||||||
|
|
||||||
|
const compat = {
|
||||||
|
...detectOpenAICompletionsCompat(model as Model<"openai-completions">),
|
||||||
|
...(model.compat as OpenAICompletionsCompat | undefined),
|
||||||
|
};
|
||||||
|
return compat.thinkingFormat === "openai" && compat.supportsReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyModelsDevReasoningOptionMetadata(model: Model<Api>): void {
|
||||||
|
const reasoningOptions = modelsDevReasoningOptions.get(getModelKey(model));
|
||||||
|
if (!reasoningOptions || !supportsDirectReasoningEffort(model)) return;
|
||||||
|
const thinkingLevelMap = getEffortThinkingLevelMap(reasoningOptions);
|
||||||
|
if (thinkingLevelMap) mergeThinkingLevelMap(model, thinkingLevelMap);
|
||||||
|
}
|
||||||
|
|
||||||
function getTogetherCompat(modelId: string, reasoning: boolean): OpenAICompletionsCompat {
|
function getTogetherCompat(modelId: string, reasoning: boolean): OpenAICompletionsCompat {
|
||||||
if (!reasoning) return TOGETHER_BASE_COMPAT;
|
if (!reasoning) return TOGETHER_BASE_COMPAT;
|
||||||
if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_REASONING_EFFORT_COMPAT;
|
if (TOGETHER_REASONING_EFFORT_MODELS.has(modelId)) return TOGETHER_REASONING_EFFORT_COMPAT;
|
||||||
@@ -536,7 +572,8 @@ function detectOpenAICompletionsCompat(model: Model<"openai-completions">): Open
|
|||||||
const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com");
|
const isDeepSeek = provider === "deepseek" || baseUrl.includes("deepseek.com");
|
||||||
const isOpenRouterDeveloperRoleModel =
|
const isOpenRouterDeveloperRoleModel =
|
||||||
isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/"));
|
isOpenRouter && (model.id.startsWith("anthropic/") || model.id.startsWith("openai/"));
|
||||||
const cacheControlFormat = provider === "openrouter" && model.id.startsWith("anthropic/") ? "anthropic" : undefined;
|
const cacheControlFormat =
|
||||||
|
provider === "openrouter" && /^~?anthropic\//.test(model.id) ? "anthropic" : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
supportsStore: !isNonStandard,
|
supportsStore: !isNonStandard,
|
||||||
@@ -957,7 +994,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
console.log("Fetching models from models.dev API...");
|
console.log("Fetching models from models.dev API...");
|
||||||
const response = await fetch("https://models.dev/api.json");
|
const response = await fetch("https://models.dev/api.json");
|
||||||
if (!response.ok) throw new Error(`models.dev API returned ${response.status}`);
|
if (!response.ok) throw new Error(`models.dev API returned ${response.status}`);
|
||||||
const data = await response.json();
|
const data = (await response.json()) as ModelsDevCatalog;
|
||||||
|
|
||||||
const models: Model<any>[] = [];
|
const models: Model<any>[] = [];
|
||||||
const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map<string, string>();
|
const nvidiaNimModelIds = data.nvidia?.models ? await fetchNvidiaNimModelIds() : new Map<string, string>();
|
||||||
@@ -997,6 +1034,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("amazon-bedrock" as const, id, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,6 +1061,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("anthropic", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,6 +1095,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: source.limit?.context || 4096,
|
contextWindow: source.limit?.context || 4096,
|
||||||
maxTokens: source.limit?.output || 4096,
|
maxTokens: source.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("google", modelId, source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,6 +1137,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: source.limit?.context || 4096,
|
contextWindow: source.limit?.context || 4096,
|
||||||
maxTokens: source.limit?.output || 4096,
|
maxTokens: source.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("google-vertex", modelId, source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1125,6 +1166,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("openai", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,6 +1193,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("groq", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,6 +1220,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("cerebras", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,6 +1248,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
compat: { sendSessionAffinityHeaders: true },
|
compat: { sendSessionAffinityHeaders: true },
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("cloudflare-workers-ai", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,6 +1305,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
...(compat ? { compat } : {}),
|
...(compat ? { compat } : {}),
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1288,6 +1334,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("xai", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1330,6 +1377,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1357,6 +1405,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("mistral", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1386,6 +1435,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("huggingface", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1423,6 +1473,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
supportsLongCacheRetention: false,
|
supportsLongCacheRetention: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("fireworks", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1457,6 +1508,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("nvidia", liveModelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1489,6 +1541,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("together", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1596,6 +1649,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions(variant.provider, modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1646,6 +1700,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
models.push(copilotModel);
|
models.push(copilotModel);
|
||||||
|
recordModelsDevReasoningOptions("github-copilot", modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1679,6 +1734,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1716,7 +1772,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
forceAdaptiveThinking: true,
|
forceAdaptiveThinking: true,
|
||||||
},
|
},
|
||||||
reasoning: isKimiK3 || m.reasoning === true,
|
reasoning: isKimiK3 || m.reasoning === true,
|
||||||
...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}),
|
|
||||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: m.cost?.input || impliedCost?.input || 0,
|
input: m.cost?.input || impliedCost?.input || 0,
|
||||||
@@ -1727,6 +1782,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions("kimi-coding", normalizedId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1761,6 +1817,8 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
if (isKimiK3) {
|
if (isKimiK3) {
|
||||||
compat.requiresReasoningContentOnAssistantMessages = true;
|
compat.requiresReasoningContentOnAssistantMessages = true;
|
||||||
compat.deferredToolsMode = "kimi";
|
compat.deferredToolsMode = "kimi";
|
||||||
|
compat.thinkingFormat = "openai";
|
||||||
|
compat.supportsReasoningEffort = true;
|
||||||
}
|
}
|
||||||
models.push({
|
models.push({
|
||||||
id: modelId,
|
id: modelId,
|
||||||
@@ -1769,7 +1827,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
provider,
|
provider,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
reasoning: isKimiK3 || m.reasoning === true,
|
reasoning: isKimiK3 || m.reasoning === true,
|
||||||
...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}),
|
|
||||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||||
cost: {
|
cost: {
|
||||||
input: m.cost?.input || (isKimiK3 ? KIMI_K3_COST.input : 0),
|
input: m.cost?.input || (isKimiK3 ? KIMI_K3_COST.input : 0),
|
||||||
@@ -1781,6 +1838,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
compat,
|
compat,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1837,6 +1895,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1888,6 +1947,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
contextWindow: m.limit?.context || 4096,
|
contextWindow: m.limit?.context || 4096,
|
||||||
maxTokens: m.limit?.output || 4096,
|
maxTokens: m.limit?.output || 4096,
|
||||||
});
|
});
|
||||||
|
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2392,8 +2452,9 @@ async function generateModels() {
|
|||||||
allModels.push(...azureOpenAiModels);
|
allModels.push(...azureOpenAiModels);
|
||||||
|
|
||||||
for (const model of allModels) {
|
for (const model of allModels) {
|
||||||
applyThinkingLevelMetadata(model);
|
|
||||||
applyOpenAICompletionsCompatMetadata(model);
|
applyOpenAICompletionsCompatMetadata(model);
|
||||||
|
applyModelsDevReasoningOptionMetadata(model);
|
||||||
|
applyThinkingLevelMetadata(model);
|
||||||
applyOpenAIToolSearchMetadata(model);
|
applyOpenAIToolSearchMetadata(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
|
||||||
|
|
||||||
|
export type ModelsDevReasoningOption =
|
||||||
|
| { type: "toggle" }
|
||||||
|
| {
|
||||||
|
type: "effort";
|
||||||
|
values: Array<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "default" | null>;
|
||||||
|
}
|
||||||
|
| { type: "budget_tokens"; min?: number; max?: number };
|
||||||
|
|
||||||
|
const THINKING_LEVELS: readonly ThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts models.dev verified effort values into Pi's selectable thinking levels.
|
||||||
|
* Values without a Pi equivalent (`default` and JSON `null`) are intentionally
|
||||||
|
* omitted.
|
||||||
|
*/
|
||||||
|
export function getEffortThinkingLevelMap(options: readonly ModelsDevReasoningOption[]): ThinkingLevelMap | undefined {
|
||||||
|
const effortValues = options.flatMap((option) => (option.type === "effort" ? option.values : []));
|
||||||
|
if (effortValues.length === 0) return undefined;
|
||||||
|
|
||||||
|
const supported = new Set(effortValues);
|
||||||
|
if (!THINKING_LEVELS.some((level) => supported.has(level)) && !supported.has("none")) return undefined;
|
||||||
|
|
||||||
|
const map: ThinkingLevelMap = { off: supported.has("none") ? "none" : null };
|
||||||
|
for (const level of THINKING_LEVELS) {
|
||||||
|
map[level] = supported.has(level) ? level : null;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
@@ -809,7 +809,7 @@ function addCacheControlToLastConversationMessage(
|
|||||||
): void {
|
): void {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const message = messages[i];
|
const message = messages[i];
|
||||||
if (message.role === "user" || message.role === "assistant") {
|
if (message.role === "user" || message.role === "assistant" || message.role === "tool") {
|
||||||
if (addCacheControlToMessage(message, cacheControl)) {
|
if (addCacheControlToMessage(message, cacheControl)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -840,7 +840,7 @@ function addCacheControlToMessage(
|
|||||||
message: ChatCompletionMessageParam,
|
message: ChatCompletionMessageParam,
|
||||||
cacheControl: OpenAICompatCacheControl,
|
cacheControl: OpenAICompatCacheControl,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (message.role === "user" || message.role === "assistant") {
|
if (message.role === "user" || message.role === "assistant" || message.role === "tool") {
|
||||||
return addCacheControlToTextContent(message, cacheControl);
|
return addCacheControlToTextContent(message, cacheControl);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -850,6 +850,7 @@ function addCacheControlToTextContent(
|
|||||||
message:
|
message:
|
||||||
| ChatCompletionInstructionMessageParam
|
| ChatCompletionInstructionMessageParam
|
||||||
| ChatCompletionAssistantMessageParam
|
| ChatCompletionAssistantMessageParam
|
||||||
|
| ChatCompletionToolMessageParam
|
||||||
| Extract<ChatCompletionMessageParam, { role: "user" }>,
|
| Extract<ChatCompletionMessageParam, { role: "user" }>,
|
||||||
cacheControl: OpenAICompatCacheControl,
|
cacheControl: OpenAICompatCacheControl,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* Kimi Code (subscription) OAuth flow
|
||||||
|
*
|
||||||
|
* RFC 8628 device authorization grant against https://auth.kimi.com with JSON
|
||||||
|
* responses. The access token authenticates requests to
|
||||||
|
* https://api.kimi.com/coding as an `Authorization: Bearer` header.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||||
|
import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts";
|
||||||
|
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||||
|
|
||||||
|
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||||
|
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
|
||||||
|
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
|
||||||
|
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||||
|
const REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||||
|
const REFRESH_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
type DeviceAuthorization = {
|
||||||
|
deviceCode: string;
|
||||||
|
userCode: string;
|
||||||
|
verificationUri: string;
|
||||||
|
verificationUriComplete: string;
|
||||||
|
intervalSeconds: number;
|
||||||
|
expiresInSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TokenResponse = {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
expires: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getOauthHost(): string {
|
||||||
|
const override = getProviderEnvValue("KIMI_CODE_OAUTH_HOST") || getProviderEnvValue("KIMI_OAUTH_HOST");
|
||||||
|
return (override || DEFAULT_OAUTH_HOST).replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSignal(signal?: AbortSignal): AbortSignal {
|
||||||
|
return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(signal ? [signal] : [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formUrlEncode(fields: Record<string, string>): string {
|
||||||
|
return new URLSearchParams(fields).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(response: Response): Promise<Record<string, unknown> | null> {
|
||||||
|
try {
|
||||||
|
const json = await response.json();
|
||||||
|
return json && typeof json === "object" ? (json as Record<string, unknown>) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
|
||||||
|
function trustedHttpUrl(value: unknown): string | null {
|
||||||
|
if (typeof value !== "string" || !value) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDeviceAuthorization(oauthHost: string, signal?: AbortSignal): Promise<DeviceAuthorization> {
|
||||||
|
const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: formUrlEncode({ client_id: CLIENT_ID }),
|
||||||
|
signal: requestSignal(signal),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
throw new Error(`Kimi Code device authorization failed with status ${response.status}${text ? `: ${text}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await readJson(response);
|
||||||
|
const deviceCode = json?.device_code;
|
||||||
|
const userCode = json?.user_code;
|
||||||
|
const verificationUri = json?.verification_uri;
|
||||||
|
const verificationUriComplete = json?.verification_uri_complete;
|
||||||
|
if (
|
||||||
|
typeof deviceCode !== "string" ||
|
||||||
|
typeof userCode !== "string" ||
|
||||||
|
typeof verificationUri !== "string" ||
|
||||||
|
typeof verificationUriComplete !== "string" ||
|
||||||
|
!trustedHttpUrl(verificationUriComplete) ||
|
||||||
|
!trustedHttpUrl(verificationUri)
|
||||||
|
) {
|
||||||
|
throw new Error(`Invalid Kimi Code device authorization response: ${JSON.stringify(json)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = json?.interval;
|
||||||
|
const expiresIn = json?.expires_in;
|
||||||
|
return {
|
||||||
|
deviceCode,
|
||||||
|
userCode,
|
||||||
|
verificationUri,
|
||||||
|
verificationUriComplete,
|
||||||
|
intervalSeconds:
|
||||||
|
typeof interval === "number" && Number.isFinite(interval) && interval > 0
|
||||||
|
? interval
|
||||||
|
: DEFAULT_POLL_INTERVAL_SECONDS,
|
||||||
|
expiresInSeconds:
|
||||||
|
typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0
|
||||||
|
? expiresIn
|
||||||
|
: DEVICE_CODE_TIMEOUT_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTokenResponse(json: Record<string, unknown> | null, operation: string): TokenResponse {
|
||||||
|
const accessToken = json?.access_token;
|
||||||
|
const refreshToken = json?.refresh_token;
|
||||||
|
const expiresIn = json?.expires_in;
|
||||||
|
if (
|
||||||
|
typeof accessToken !== "string" ||
|
||||||
|
!accessToken ||
|
||||||
|
typeof refreshToken !== "string" ||
|
||||||
|
!refreshToken ||
|
||||||
|
typeof expiresIn !== "number" ||
|
||||||
|
!Number.isFinite(expiresIn) ||
|
||||||
|
expiresIn <= 0
|
||||||
|
) {
|
||||||
|
throw new Error(`Kimi Code token ${operation} response missing fields: ${JSON.stringify(json)}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
access: accessToken,
|
||||||
|
refresh: refreshToken,
|
||||||
|
expires: Date.now() + expiresIn * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollForToken(
|
||||||
|
oauthHost: string,
|
||||||
|
device: DeviceAuthorization,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<TokenResponse> {
|
||||||
|
return pollOAuthDeviceCodeFlow<TokenResponse>({
|
||||||
|
intervalSeconds: device.intervalSeconds,
|
||||||
|
expiresInSeconds: device.expiresInSeconds,
|
||||||
|
waitBeforeFirstPoll: true,
|
||||||
|
signal,
|
||||||
|
poll: async () => {
|
||||||
|
const response = await fetch(`${oauthHost}/api/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: formUrlEncode({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
device_code: device.deviceCode,
|
||||||
|
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
}),
|
||||||
|
signal: requestSignal(signal),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 500) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
message: `Kimi Code device token request failed with status ${response.status}${text ? `: ${text}` : ""}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await readJson(response);
|
||||||
|
if (response.ok && typeof json?.access_token === "string") {
|
||||||
|
try {
|
||||||
|
return { status: "complete", value: parseTokenResponse(json, "poll") };
|
||||||
|
} catch (error) {
|
||||||
|
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = json?.error;
|
||||||
|
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
|
||||||
|
if (error === "authorization_pending") {
|
||||||
|
return { status: "pending" };
|
||||||
|
}
|
||||||
|
if (error === "slow_down") {
|
||||||
|
const interval = json?.interval;
|
||||||
|
return {
|
||||||
|
status: "slow_down",
|
||||||
|
intervalSeconds: typeof interval === "number" && interval > 0 ? interval : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error === "expired_token") {
|
||||||
|
return { status: "failed", message: "Kimi Code device authorization expired. Please restart login." };
|
||||||
|
}
|
||||||
|
if (error === "access_denied") {
|
||||||
|
return { status: "failed", message: "Kimi Code login was denied." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
message: `Kimi Code device token request failed (status ${response.status})${typeof error === "string" ? `: ${error}${description}` : ""}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableRefreshFailure(response: Response): boolean {
|
||||||
|
return response.status === 429 || response.status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshToken(
|
||||||
|
oauthHost: string,
|
||||||
|
refreshTokenValue: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<TokenResponse> {
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
for (let attempt = 0; attempt <= REFRESH_MAX_RETRIES; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
await sleep(1000 * 2 ** (attempt - 1));
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("Kimi Code token refresh aborted");
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${oauthHost}/api/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: formUrlEncode({
|
||||||
|
client_id: CLIENT_ID,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshTokenValue,
|
||||||
|
}),
|
||||||
|
signal: requestSignal(signal),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await readJson(response);
|
||||||
|
if (response.ok) {
|
||||||
|
return parseTokenResponse(json, "refresh");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthorized: the stored credential is dead; Models clears it and prompts re-login.
|
||||||
|
if (response.status === 401 || response.status === 403 || json?.error === "invalid_grant") {
|
||||||
|
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
|
||||||
|
throw new Error(`Kimi Code token refresh unauthorized (status ${response.status})${description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRetryableRefreshFailure(response) && attempt < REFRESH_MAX_RETRIES) {
|
||||||
|
lastError = new Error(`Kimi Code token refresh failed with status ${response.status}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = JSON.stringify(json);
|
||||||
|
throw new Error(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error("Kimi Code token refresh failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginKimiCoding(interaction: AuthInteraction): Promise<OAuthCredential> {
|
||||||
|
const oauthHost = getOauthHost();
|
||||||
|
const device = await startDeviceAuthorization(oauthHost, interaction.signal);
|
||||||
|
interaction.notify({
|
||||||
|
type: "device_code",
|
||||||
|
userCode: device.userCode,
|
||||||
|
verificationUri: device.verificationUriComplete,
|
||||||
|
intervalSeconds: device.intervalSeconds,
|
||||||
|
expiresInSeconds: device.expiresInSeconds,
|
||||||
|
});
|
||||||
|
const token = await pollForToken(oauthHost, device, interaction.signal);
|
||||||
|
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kimiCodingOAuth: OAuthAuth = {
|
||||||
|
name: "Kimi Code (subscription)",
|
||||||
|
loginLabel: "Sign in with Kimi Code",
|
||||||
|
|
||||||
|
login: loginKimiCoding,
|
||||||
|
|
||||||
|
refresh: async (credential, signal) => {
|
||||||
|
const token = await refreshToken(getOauthHost(), credential.refresh, signal);
|
||||||
|
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
|
||||||
|
},
|
||||||
|
|
||||||
|
async toAuth(credential) {
|
||||||
|
return { headers: { Authorization: `Bearer ${credential.access}` } };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ type OAuthFlowLoaders = {
|
|||||||
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
|
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
|
||||||
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
||||||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||||
|
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||||
xai: () => OAuthAuth | Promise<OAuthAuth>;
|
xai: () => OAuthAuth | Promise<OAuthAuth>;
|
||||||
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
|
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
|
||||||
};
|
};
|
||||||
@@ -41,6 +42,11 @@ export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
|
|||||||
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
|
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
|
||||||
|
if (bundledLoaders) return bundledLoaders.kimiCoding();
|
||||||
|
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
|
||||||
|
};
|
||||||
|
|
||||||
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
|
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
|
||||||
if (bundledLoaders) return bundledLoaders.xai();
|
if (bundledLoaders) return bundledLoaders.xai();
|
||||||
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
|
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { anthropicOAuth } from "./auth/oauth/anthropic.ts";
|
import { anthropicOAuth } from "./auth/oauth/anthropic.ts";
|
||||||
import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
|
import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
|
||||||
|
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
|
||||||
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
|
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
|
||||||
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
|
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
|
||||||
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
|
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
|
||||||
@@ -11,6 +12,7 @@ export function registerBunOAuthFlows(): void {
|
|||||||
anthropic: () => anthropicOAuth,
|
anthropic: () => anthropicOAuth,
|
||||||
openaiCodex: () => openaiCodexOAuth,
|
openaiCodex: () => openaiCodexOAuth,
|
||||||
githubCopilot: () => githubCopilotOAuth,
|
githubCopilot: () => githubCopilotOAuth,
|
||||||
|
kimiCoding: () => kimiCodingOAuth,
|
||||||
xai: () => xaiOAuth,
|
xai: () => xaiOAuth,
|
||||||
radius: createRadiusOAuth,
|
radius: createRadiusOAuth,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ export const GOOGLE_MODELS = values as {
|
|||||||
id: "gemini-3.5-flash";
|
id: "gemini-3.5-flash";
|
||||||
provider: "google";
|
provider: "google";
|
||||||
};
|
};
|
||||||
|
"gemini-3.5-flash-lite": Model<"google-generative-ai"> & {
|
||||||
|
id: "gemini-3.5-flash-lite";
|
||||||
|
provider: "google";
|
||||||
|
};
|
||||||
|
"gemini-3.6-flash": Model<"google-generative-ai"> & {
|
||||||
|
id: "gemini-3.6-flash";
|
||||||
|
provider: "google";
|
||||||
|
};
|
||||||
"gemini-flash-latest": Model<"google-generative-ai"> & {
|
"gemini-flash-latest": Model<"google-generative-ai"> & {
|
||||||
id: "gemini-flash-latest";
|
id: "gemini-flash-latest";
|
||||||
provider: "google";
|
provider: "google";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
||||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
||||||
|
import { loadKimiCodingOAuth } from "../auth/oauth/load.ts";
|
||||||
import { createProvider, type Provider } from "../models.ts";
|
import { createProvider, type Provider } from "../models.ts";
|
||||||
import { KIMI_CODING_MODELS } from "./kimi-coding.models.ts";
|
import { KIMI_CODING_MODELS } from "./kimi-coding.models.ts";
|
||||||
|
|
||||||
@@ -8,7 +9,14 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> {
|
|||||||
id: "kimi-coding",
|
id: "kimi-coding",
|
||||||
name: "Kimi For Coding",
|
name: "Kimi For Coding",
|
||||||
baseUrl: "https://api.kimi.com/coding",
|
baseUrl: "https://api.kimi.com/coding",
|
||||||
auth: { apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]) },
|
auth: {
|
||||||
|
apiKey: envApiKeyAuth("Kimi API key", ["KIMI_API_KEY"]),
|
||||||
|
oauth: lazyOAuth({
|
||||||
|
name: "Kimi Code (subscription)",
|
||||||
|
loginLabel: "Sign in with Kimi Code",
|
||||||
|
load: loadKimiCodingOAuth,
|
||||||
|
}),
|
||||||
|
},
|
||||||
models: Object.values(KIMI_CODING_MODELS),
|
models: Object.values(KIMI_CODING_MODELS),
|
||||||
api: anthropicMessagesApi(),
|
api: anthropicMessagesApi(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ export const OPENCODE_MODELS = values as {
|
|||||||
id: "gemini-3.5-flash";
|
id: "gemini-3.5-flash";
|
||||||
provider: "opencode";
|
provider: "opencode";
|
||||||
};
|
};
|
||||||
|
"gemini-3.5-flash-lite": Model<"google-generative-ai"> & {
|
||||||
|
id: "gemini-3.5-flash-lite";
|
||||||
|
provider: "opencode";
|
||||||
|
};
|
||||||
|
"gemini-3.6-flash": Model<"google-generative-ai"> & {
|
||||||
|
id: "gemini-3.6-flash";
|
||||||
|
provider: "opencode";
|
||||||
|
};
|
||||||
"glm-5": Model<"openai-completions"> & {
|
"glm-5": Model<"openai-completions"> & {
|
||||||
id: "glm-5";
|
id: "glm-5";
|
||||||
provider: "opencode";
|
provider: "opencode";
|
||||||
@@ -185,6 +193,10 @@ export const OPENCODE_MODELS = values as {
|
|||||||
id: "kimi-k2.7-code";
|
id: "kimi-k2.7-code";
|
||||||
provider: "opencode";
|
provider: "opencode";
|
||||||
};
|
};
|
||||||
|
"laguna-s-2.1-free": Model<"openai-completions"> & {
|
||||||
|
id: "laguna-s-2.1-free";
|
||||||
|
provider: "opencode";
|
||||||
|
};
|
||||||
"mimo-v2.5-free": Model<"openai-completions"> & {
|
"mimo-v2.5-free": Model<"openai-completions"> & {
|
||||||
id: "mimo-v2.5-free";
|
id: "mimo-v2.5-free";
|
||||||
provider: "opencode";
|
provider: "opencode";
|
||||||
|
|||||||
@@ -229,6 +229,14 @@ export const OPENROUTER_MODELS = values as {
|
|||||||
id: "google/gemini-3.5-flash";
|
id: "google/gemini-3.5-flash";
|
||||||
provider: "openrouter";
|
provider: "openrouter";
|
||||||
};
|
};
|
||||||
|
"google/gemini-3.5-flash-lite": Model<"openai-completions"> & {
|
||||||
|
id: "google/gemini-3.5-flash-lite";
|
||||||
|
provider: "openrouter";
|
||||||
|
};
|
||||||
|
"google/gemini-3.6-flash": Model<"openai-completions"> & {
|
||||||
|
id: "google/gemini-3.6-flash";
|
||||||
|
provider: "openrouter";
|
||||||
|
};
|
||||||
"google/gemma-3-12b-it": Model<"openai-completions"> & {
|
"google/gemma-3-12b-it": Model<"openai-completions"> & {
|
||||||
id: "google/gemma-3-12b-it";
|
id: "google/gemma-3-12b-it";
|
||||||
provider: "openrouter";
|
provider: "openrouter";
|
||||||
@@ -737,6 +745,14 @@ export const OPENROUTER_MODELS = values as {
|
|||||||
id: "poolside/laguna-m.1:free";
|
id: "poolside/laguna-m.1:free";
|
||||||
provider: "openrouter";
|
provider: "openrouter";
|
||||||
};
|
};
|
||||||
|
"poolside/laguna-s-2.1": Model<"openai-completions"> & {
|
||||||
|
id: "poolside/laguna-s-2.1";
|
||||||
|
provider: "openrouter";
|
||||||
|
};
|
||||||
|
"poolside/laguna-s-2.1:free": Model<"openai-completions"> & {
|
||||||
|
id: "poolside/laguna-s-2.1:free";
|
||||||
|
provider: "openrouter";
|
||||||
|
};
|
||||||
"poolside/laguna-xs-2.1": Model<"openai-completions"> & {
|
"poolside/laguna-xs-2.1": Model<"openai-completions"> & {
|
||||||
id: "poolside/laguna-xs-2.1";
|
id: "poolside/laguna-xs-2.1";
|
||||||
provider: "openrouter";
|
provider: "openrouter";
|
||||||
|
|||||||
@@ -265,6 +265,14 @@ export const VERCEL_AI_GATEWAY_MODELS = values as {
|
|||||||
id: "google/gemini-3.5-flash";
|
id: "google/gemini-3.5-flash";
|
||||||
provider: "vercel-ai-gateway";
|
provider: "vercel-ai-gateway";
|
||||||
};
|
};
|
||||||
|
"google/gemini-3.5-flash-lite": Model<"anthropic-messages"> & {
|
||||||
|
id: "google/gemini-3.5-flash-lite";
|
||||||
|
provider: "vercel-ai-gateway";
|
||||||
|
};
|
||||||
|
"google/gemini-3.6-flash": Model<"anthropic-messages"> & {
|
||||||
|
id: "google/gemini-3.6-flash";
|
||||||
|
provider: "vercel-ai-gateway";
|
||||||
|
};
|
||||||
"google/gemma-4-26b-a4b-it": Model<"anthropic-messages"> & {
|
"google/gemma-4-26b-a4b-it": Model<"anthropic-messages"> & {
|
||||||
id: "google/gemma-4-26b-a4b-it";
|
id: "google/gemma-4-26b-a4b-it";
|
||||||
provider: "vercel-ai-gateway";
|
provider: "vercel-ai-gateway";
|
||||||
@@ -629,6 +637,14 @@ export const VERCEL_AI_GATEWAY_MODELS = values as {
|
|||||||
id: "openai/o4-mini";
|
id: "openai/o4-mini";
|
||||||
provider: "vercel-ai-gateway";
|
provider: "vercel-ai-gateway";
|
||||||
};
|
};
|
||||||
|
"poolside/laguna-s-2.1": Model<"anthropic-messages"> & {
|
||||||
|
id: "poolside/laguna-s-2.1";
|
||||||
|
provider: "vercel-ai-gateway";
|
||||||
|
};
|
||||||
|
"poolside/laguna-s-2.1-free": Model<"anthropic-messages"> & {
|
||||||
|
id: "poolside/laguna-s-2.1-free";
|
||||||
|
provider: "vercel-ai-gateway";
|
||||||
|
};
|
||||||
"sakana/fugu-ultra": Model<"anthropic-messages"> & {
|
"sakana/fugu-ultra": Model<"anthropic-messages"> & {
|
||||||
id: "sakana/fugu-ultra";
|
id: "sakana/fugu-ultra";
|
||||||
provider: "vercel-ai-gateway";
|
provider: "vercel-ai-gateway";
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ export interface OpenAICompletionsCompat {
|
|||||||
zaiToolStream?: boolean;
|
zaiToolStream?: boolean;
|
||||||
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
|
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
|
||||||
supportsStrictMode?: boolean;
|
supportsStrictMode?: boolean;
|
||||||
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */
|
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content. */
|
||||||
cacheControlFormat?: "anthropic";
|
cacheControlFormat?: "anthropic";
|
||||||
/** Whether to send session-affinity data from `options.sessionId`. Default: false. */
|
/** Whether to send session-affinity data from `options.sessionId`. Default: false. */
|
||||||
sendSessionAffinityHeaders?: boolean;
|
sendSessionAffinityHeaders?: boolean;
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
|||||||
"connection.?lost",
|
"connection.?lost",
|
||||||
"other side closed",
|
"other side closed",
|
||||||
"fetch failed",
|
"fetch failed",
|
||||||
|
"getaddrinfo",
|
||||||
|
"ENOTFOUND",
|
||||||
|
"EAI_AGAIN",
|
||||||
"upstream.?connect",
|
"upstream.?connect",
|
||||||
"reset before headers",
|
"reset before headers",
|
||||||
"socket hang up",
|
"socket hang up",
|
||||||
@@ -85,6 +88,128 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
|||||||
"ResourceExhausted",
|
"ResourceExhausted",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry policy: bounded attempts with exponential backoff (`baseDelayMs * 2^(attempt-1)`).
|
||||||
|
* Matches `settings.retry` (`enabled`, `maxRetries`, `baseDelayMs`) in coding-agent; kept
|
||||||
|
* here so the classifier and the policy-driven retry loop live together and stay reusable
|
||||||
|
* by the SDK and other callers.
|
||||||
|
*/
|
||||||
|
export interface RetryPolicy {
|
||||||
|
enabled: boolean;
|
||||||
|
/** Max retry attempts (0 = no retries). The initial call never counts as a retry. */
|
||||||
|
maxRetries: number;
|
||||||
|
/** Base delay in ms. Per-attempt delay is `baseDelayMs * 2^(attempt-1)` before jitter. */
|
||||||
|
baseDelayMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional callbacks emitted by {@link retryAssistantCall} around each retry. */
|
||||||
|
export interface RetryCallbacks {
|
||||||
|
/** Emitted before the backoff sleep of each retry attempt (1-indexed). */
|
||||||
|
onRetryScheduled?: (
|
||||||
|
attempt: number,
|
||||||
|
maxAttempts: number,
|
||||||
|
delayMs: number,
|
||||||
|
errorMessage: string,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
/** Emitted after the backoff sleep, immediately before the retried call starts. */
|
||||||
|
onRetryAttemptStart?: () => void | Promise<void>;
|
||||||
|
/** Emitted once when the loop ends: success if a later call completed normally. */
|
||||||
|
onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RetrySleepAbortError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Aborted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new RetrySleepAbortError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new RetrySleepAbortError());
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a single assistant-producing call with bounded retry on transient errors.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - A successful response is returned immediately. Aborts are terminal and never
|
||||||
|
* retried, but reported as unsuccessful if they happen after a retry was scheduled.
|
||||||
|
* Aborts during the backoff sleep are normalized to an aborted `AssistantMessage`
|
||||||
|
* too, so callers do not need to care when cancellation happened.
|
||||||
|
* - A non-retryable error (per {@link isRetryableAssistantError}, including quota/
|
||||||
|
* billing exhaustion) is returned immediately so deterministic errors fail fast.
|
||||||
|
* - Otherwise retries up to `maxRetries` times with exponential backoff, emitting
|
||||||
|
* `onRetryScheduled` before each sleep, `onRetryAttemptStart` after each sleep before
|
||||||
|
* the retried call starts, and `onRetryFinished` once at the end (whether the loop
|
||||||
|
* ends in success, exhausted retries, or an aborted backoff).
|
||||||
|
*
|
||||||
|
* When `policy` is undefined or disabled, the first response is returned unchanged
|
||||||
|
* (equivalent to calling `produce()` directly).
|
||||||
|
*/
|
||||||
|
export async function retryAssistantCall(
|
||||||
|
produce: () => Promise<AssistantMessage>,
|
||||||
|
policy: RetryPolicy | undefined,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
|
): Promise<AssistantMessage> {
|
||||||
|
const maxAttempts = policy?.enabled ? policy.maxRetries : 0;
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
let lastRetry: { attempt: number; errorMessage: string } | undefined;
|
||||||
|
for (;;) {
|
||||||
|
const response = await produce();
|
||||||
|
|
||||||
|
// Abort: terminal but not successful. Never retry an aborted message.
|
||||||
|
if (response.stopReason === "aborted") {
|
||||||
|
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: non-error, non-abort responses return as-is.
|
||||||
|
if (response.stopReason !== "error") {
|
||||||
|
if (lastRetry) await callbacks?.onRetryFinished?.(true, lastRetry.attempt);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-retryable, or budget exhausted: return the final error message.
|
||||||
|
if (attempt >= maxAttempts || !isRetryableAssistantError(response)) {
|
||||||
|
if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt, response.errorMessage);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
lastRetry = { attempt, errorMessage: response.errorMessage || "Unknown error" };
|
||||||
|
const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
|
||||||
|
await callbacks?.onRetryScheduled?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage);
|
||||||
|
|
||||||
|
// Normalize aborts during retry backoff to the same AssistantMessage shape as
|
||||||
|
// provider stream aborts, so callers do not need to care when cancellation happened.
|
||||||
|
try {
|
||||||
|
await sleep(delayMs, signal);
|
||||||
|
} catch (error) {
|
||||||
|
await callbacks?.onRetryFinished?.(false, attempt, lastRetry.errorMessage);
|
||||||
|
if (error instanceof RetrySleepAbortError) {
|
||||||
|
return { ...response, stopReason: "aborted", errorMessage: undefined };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await callbacks?.onRetryAttemptStart?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classifies whether a failed assistant message looks like a transient provider
|
* Classifies whether a failed assistant message looks like a transient provider
|
||||||
* or transport error, so callers can decide if the last assistant turn should be
|
* or transport error, so callers can decide if the last assistant turn should be
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts";
|
||||||
|
import type { AuthInteraction } from "../src/auth/types.ts";
|
||||||
|
|
||||||
|
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||||
|
const OAUTH_HOST = "https://auth.kimi.com";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUrl(input: unknown): string {
|
||||||
|
if (typeof input === "string") return input;
|
||||||
|
if (input instanceof URL) return input.toString();
|
||||||
|
if (input instanceof Request) return input.url;
|
||||||
|
throw new Error(`Unsupported fetch input: ${String(input)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceAuthorizationResponse(overrides?: Record<string, unknown>): Response {
|
||||||
|
return jsonResponse({
|
||||||
|
user_code: "ABCD-1234",
|
||||||
|
device_code: "device-code-123",
|
||||||
|
verification_uri: "https://www.kimi.com/code",
|
||||||
|
verification_uri_complete: "https://www.kimi.com/code?user_code=ABCD-1234",
|
||||||
|
interval: 5,
|
||||||
|
expires_in: 600,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInteraction(events: Array<Record<string, unknown>>): AuthInteraction {
|
||||||
|
return {
|
||||||
|
prompt: async () => {
|
||||||
|
throw new Error("Kimi Code login should not prompt");
|
||||||
|
},
|
||||||
|
notify: (event) => events.push(event),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Kimi Code OAuth", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs in with the device authorization flow", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const startTime = new Date("2026-07-20T00:00:00Z");
|
||||||
|
vi.setSystemTime(startTime);
|
||||||
|
|
||||||
|
const events: Array<Record<string, unknown>> = [];
|
||||||
|
const pollResponses = [
|
||||||
|
jsonResponse({ error: "authorization_pending" }, 400),
|
||||||
|
jsonResponse({ access_token: "access-token", refresh_token: "refresh-token", expires_in: 3600 }),
|
||||||
|
];
|
||||||
|
const pollTimes: number[] = [];
|
||||||
|
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = getUrl(input);
|
||||||
|
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
||||||
|
expect(init?.method).toBe("POST");
|
||||||
|
expect(init?.headers).toMatchObject({
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
});
|
||||||
|
expect(new URLSearchParams(String(init?.body)).get("client_id")).toBe(CLIENT_ID);
|
||||||
|
return deviceAuthorizationResponse();
|
||||||
|
}
|
||||||
|
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
||||||
|
pollTimes.push(Date.now());
|
||||||
|
const params = new URLSearchParams(String(init?.body));
|
||||||
|
expect(params.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
|
||||||
|
expect(params.get("client_id")).toBe(CLIENT_ID);
|
||||||
|
expect(params.get("device_code")).toBe("device-code-123");
|
||||||
|
const response = pollResponses.shift();
|
||||||
|
if (!response) throw new Error("Unexpected extra token poll");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const credentialPromise = kimiCodingOAuth.login(createInteraction(events));
|
||||||
|
for (let i = 0; i < 5 && events.length === 0; i++) {
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
}
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "device_code",
|
||||||
|
userCode: "ABCD-1234",
|
||||||
|
verificationUri: "https://www.kimi.com/code?user_code=ABCD-1234",
|
||||||
|
intervalSeconds: 5,
|
||||||
|
expiresInSeconds: 600,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// waitBeforeFirstPoll: first poll happens after the 5s interval.
|
||||||
|
await vi.advanceTimersByTimeAsync(4999);
|
||||||
|
expect(pollTimes).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(pollTimes).toEqual([startTime.getTime() + 5000]);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
await expect(credentialPromise).resolves.toEqual({
|
||||||
|
type: "oauth",
|
||||||
|
access: "access-token",
|
||||||
|
refresh: "refresh-token",
|
||||||
|
expires: startTime.getTime() + 10000 + 3600 * 1000,
|
||||||
|
});
|
||||||
|
expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the device code expires", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: unknown): Promise<Response> => {
|
||||||
|
const url = getUrl(input);
|
||||||
|
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
||||||
|
return deviceAuthorizationResponse();
|
||||||
|
}
|
||||||
|
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
||||||
|
return jsonResponse({ error: "expired_token" }, 400);
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
||||||
|
const assertion = expect(credentialPromise).rejects.toThrow("expired");
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
await assertion;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the user denies the login", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: unknown): Promise<Response> => {
|
||||||
|
const url = getUrl(input);
|
||||||
|
if (url === `${OAUTH_HOST}/api/oauth/device_authorization`) {
|
||||||
|
return deviceAuthorizationResponse();
|
||||||
|
}
|
||||||
|
if (url === `${OAUTH_HOST}/api/oauth/token`) {
|
||||||
|
return jsonResponse({ error: "access_denied" }, 400);
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
||||||
|
const assertion = expect(credentialPromise).rejects.toThrow("denied");
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
await assertion;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors the KIMI_CODE_OAUTH_HOST override", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubEnv("KIMI_CODE_OAUTH_HOST", "https://auth.example.com/");
|
||||||
|
|
||||||
|
const urls: string[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: unknown): Promise<Response> => {
|
||||||
|
const url = getUrl(input);
|
||||||
|
urls.push(url);
|
||||||
|
if (url === "https://auth.example.com/api/oauth/device_authorization") {
|
||||||
|
return deviceAuthorizationResponse({ interval: 1 });
|
||||||
|
}
|
||||||
|
if (url === "https://auth.example.com/api/oauth/token") {
|
||||||
|
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const credentialPromise = kimiCodingOAuth.login(createInteraction([]));
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
await expect(credentialPromise).resolves.toMatchObject({ access: "a", refresh: "r" });
|
||||||
|
expect(urls).toEqual([
|
||||||
|
"https://auth.example.com/api/oauth/device_authorization",
|
||||||
|
"https://auth.example.com/api/oauth/token",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes tokens and returns a Bearer header for requests", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = getUrl(input);
|
||||||
|
expect(url).toBe(`${OAUTH_HOST}/api/oauth/token`);
|
||||||
|
const params = new URLSearchParams(String(init?.body));
|
||||||
|
expect(params.get("grant_type")).toBe("refresh_token");
|
||||||
|
expect(params.get("refresh_token")).toBe("old-refresh");
|
||||||
|
expect(params.get("client_id")).toBe(CLIENT_ID);
|
||||||
|
return jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
const credential = await kimiCodingOAuth.refresh({
|
||||||
|
type: "oauth",
|
||||||
|
access: "old-access",
|
||||||
|
refresh: "old-refresh",
|
||||||
|
expires: before,
|
||||||
|
});
|
||||||
|
expect(credential).toEqual({
|
||||||
|
type: "oauth",
|
||||||
|
access: "new-access",
|
||||||
|
refresh: "new-refresh",
|
||||||
|
expires: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(credential.expires).toBeGreaterThanOrEqual(before + 3600 * 1000);
|
||||||
|
|
||||||
|
await expect(kimiCodingOAuth.toAuth(credential)).resolves.toEqual({
|
||||||
|
headers: { Authorization: "Bearer new-access" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries refresh on 429 and fails unauthorized on invalid_grant", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// 429 once, then success.
|
||||||
|
let calls = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (): Promise<Response> => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) return jsonResponse({ error: "temporarily_unavailable" }, 429);
|
||||||
|
return jsonResponse({ access_token: "a", refresh_token: "r", expires_in: 60 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshPromise = kimiCodingOAuth.refresh({
|
||||||
|
type: "oauth",
|
||||||
|
access: "old",
|
||||||
|
refresh: "old",
|
||||||
|
expires: 0,
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
await expect(refreshPromise).resolves.toMatchObject({ access: "a" });
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
|
||||||
|
// invalid_grant is not retried.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (): Promise<Response> => jsonResponse({ error: "invalid_grant" }, 400)),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
kimiCodingOAuth.refresh({ type: "oauth", access: "old", refresh: "old", expires: 0 }),
|
||||||
|
).rejects.toThrow("unauthorized");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { Type } from "typebox";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
import { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||||
import { getModel } from "../src/compat.ts";
|
import { getModel } from "../src/compat.ts";
|
||||||
import type { Model } from "../src/types.ts";
|
import type { Message, Model } from "../src/types.ts";
|
||||||
|
|
||||||
interface CacheControl {
|
interface CacheControl {
|
||||||
type: "ephemeral";
|
type: "ephemeral";
|
||||||
@@ -74,6 +74,7 @@ vi.mock("openai", () => {
|
|||||||
async function capturePayload(
|
async function capturePayload(
|
||||||
model: Model<"openai-completions">,
|
model: Model<"openai-completions">,
|
||||||
options?: { cacheRetention?: "none" | "short" | "long" },
|
options?: { cacheRetention?: "none" | "short" | "long" },
|
||||||
|
messages?: Message[],
|
||||||
): Promise<CapturedParams> {
|
): Promise<CapturedParams> {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ async function capturePayload(
|
|||||||
model,
|
model,
|
||||||
{
|
{
|
||||||
systemPrompt: "System prompt",
|
systemPrompt: "System prompt",
|
||||||
messages: [{ role: "user", content: "Hello", timestamp }],
|
messages: messages ?? [{ role: "user", content: "Hello", timestamp }],
|
||||||
tools: [
|
tools: [
|
||||||
{
|
{
|
||||||
name: "read",
|
name: "read",
|
||||||
@@ -158,6 +159,47 @@ describe("openai-completions cacheControlFormat", () => {
|
|||||||
expectAnthropicCacheMarkers(params);
|
expectAnthropicCacheMarkers(params);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moves the conversation cache marker to a tool result", async () => {
|
||||||
|
const model = getModel("openrouter", "anthropic/claude-sonnet-4");
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const params = await capturePayload(model, undefined, [
|
||||||
|
{ role: "user", content: "Read the file", timestamp },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "README.md" } }],
|
||||||
|
api: "openai-completions",
|
||||||
|
provider: "openrouter",
|
||||||
|
model: model.id,
|
||||||
|
usage: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
},
|
||||||
|
stopReason: "toolUse",
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "toolResult",
|
||||||
|
toolCallId: "call_1",
|
||||||
|
toolName: "read",
|
||||||
|
content: [{ type: "text", text: "file contents" }],
|
||||||
|
isError: false,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userMessage = params.messages.find((message) => message.role === "user");
|
||||||
|
expect(userMessage?.content).toBe("Read the file");
|
||||||
|
|
||||||
|
const toolMessage = params.messages[params.messages.length - 1];
|
||||||
|
expect(toolMessage.role).toBe("tool");
|
||||||
|
expect(Array.isArray(toolMessage.content)).toBe(true);
|
||||||
|
expect((toolMessage.content as TextPart[])[0]?.cache_control).toEqual({ type: "ephemeral" });
|
||||||
|
});
|
||||||
|
|
||||||
it("omits Anthropic-style cache markers when cacheRetention is none", async () => {
|
it("omits Anthropic-style cache markers when cacheRetention is none", async () => {
|
||||||
const model: Model<"openai-completions"> = {
|
const model: Model<"openai-completions"> = {
|
||||||
id: "custom-qwen",
|
id: "custom-qwen",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getModel } from "../src/compat.ts";
|
||||||
|
|
||||||
|
const OPENROUTER_ANTHROPIC_LATEST_MODEL_IDS = [
|
||||||
|
"~anthropic/claude-fable-latest",
|
||||||
|
"~anthropic/claude-haiku-latest",
|
||||||
|
"~anthropic/claude-opus-latest",
|
||||||
|
"~anthropic/claude-sonnet-latest",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("OpenRouter Anthropic cache control metadata", () => {
|
||||||
|
it.each(OPENROUTER_ANTHROPIC_LATEST_MODEL_IDS)("enables cache control for %s", (modelId) => {
|
||||||
|
expect(getModel("openrouter", modelId).compat?.cacheControlFormat).toBe("anthropic");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getEffortThinkingLevelMap } from "../scripts/models-dev-reasoning-options.ts";
|
||||||
|
|
||||||
|
describe("getEffortThinkingLevelMap", () => {
|
||||||
|
it("exposes only verified effort values and none", () => {
|
||||||
|
expect(
|
||||||
|
getEffortThinkingLevelMap([{ type: "toggle" }, { type: "effort", values: ["none", "low", "high", "max"] }]),
|
||||||
|
).toEqual({
|
||||||
|
off: "none",
|
||||||
|
minimal: null,
|
||||||
|
low: "low",
|
||||||
|
medium: null,
|
||||||
|
high: "high",
|
||||||
|
xhigh: null,
|
||||||
|
max: "max",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not infer thinking-off from an effort list", () => {
|
||||||
|
expect(getEffortThinkingLevelMap([{ type: "effort", values: ["low", "high", "max"] }])).toEqual({
|
||||||
|
off: null,
|
||||||
|
minimal: null,
|
||||||
|
low: "low",
|
||||||
|
medium: null,
|
||||||
|
high: "high",
|
||||||
|
xhigh: null,
|
||||||
|
max: "max",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves toggle and budget controls for their adapter-specific implementations", () => {
|
||||||
|
expect(getEffortThinkingLevelMap([{ type: "toggle" }])).toBeUndefined();
|
||||||
|
expect(getEffortThinkingLevelMap([{ type: "budget_tokens", min: 1024, max: 32000 }])).toBeUndefined();
|
||||||
|
expect(getEffortThinkingLevelMap([{ type: "effort", values: [null, "default"] }])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { fauxAssistantMessage } from "../src/providers/faux.ts";
|
import { fauxAssistantMessage } from "../src/providers/faux.ts";
|
||||||
import { isRetryableAssistantError } from "../src/utils/retry.ts";
|
import { isRetryableAssistantError, type RetryPolicy, retryAssistantCall } from "../src/utils/retry.ts";
|
||||||
|
|
||||||
const openAIExplicitRetryMessage =
|
const openAIExplicitRetryMessage =
|
||||||
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_******** in your message.";
|
"An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID req_******** in your message.";
|
||||||
@@ -10,6 +10,8 @@ const nvidiaNIMResourceExhaustedMessage = "ResourceExhausted: Worker local total
|
|||||||
const bunFetchSocketClosedMessage =
|
const bunFetchSocketClosedMessage =
|
||||||
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()";
|
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()";
|
||||||
const openAIResponsesEarlyEofMessage = "OpenAI Responses stream ended before a terminal response event";
|
const openAIResponsesEarlyEofMessage = "OpenAI Responses stream ended before a terminal response event";
|
||||||
|
const wrappedDnsLookupError =
|
||||||
|
"The pending stream has been canceled (caused by: getaddrinfo ENOTFOUND bedrock-runtime.us-east-1.amazonaws.com)";
|
||||||
|
|
||||||
describe("provider retry classification", () => {
|
describe("provider retry classification", () => {
|
||||||
it("matches explicit provider retry guidance", () => {
|
it("matches explicit provider retry guidance", () => {
|
||||||
@@ -38,6 +40,15 @@ describe("provider retry classification", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
wrappedDnsLookupError,
|
||||||
|
"connect ENOTFOUND api.example.com",
|
||||||
|
"EAI_AGAIN api.example.com",
|
||||||
|
"getaddrinfo failed for api.example.com",
|
||||||
|
])("matches DNS transport failure wording: %s", (errorMessage) => {
|
||||||
|
expect(isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage }))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("matches OpenAI Responses streams that end before terminal events", () => {
|
it("matches OpenAI Responses streams that end before terminal events", () => {
|
||||||
expect(
|
expect(
|
||||||
isRetryableAssistantError(
|
isRetryableAssistantError(
|
||||||
@@ -66,3 +77,136 @@ describe("provider retry classification", () => {
|
|||||||
expect(isRetryableAssistantError(fauxAssistantMessage("not an error"))).toBe(false);
|
expect(isRetryableAssistantError(fauxAssistantMessage("not an error"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("retryAssistantCall", () => {
|
||||||
|
const disabled: RetryPolicy = { enabled: false, maxRetries: 3, baseDelayMs: 0 };
|
||||||
|
const enabled: RetryPolicy = { enabled: true, maxRetries: 3, baseDelayMs: 0 };
|
||||||
|
|
||||||
|
it("returns a successful response immediately without retrying", async () => {
|
||||||
|
const produce = vi.fn(async () => fauxAssistantMessage("ok"));
|
||||||
|
const res = await retryAssistantCall(produce, enabled, undefined);
|
||||||
|
expect(res.content).toEqual([{ type: "text", text: "ok" }]);
|
||||||
|
expect(produce).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry an aborted message", async () => {
|
||||||
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "aborted" }));
|
||||||
|
const onRetryScheduled = vi.fn();
|
||||||
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled });
|
||||||
|
expect(res.stopReason).toBe("aborted");
|
||||||
|
expect(produce).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRetryScheduled).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry a non-retryable error (quota/billing)", async () => {
|
||||||
|
const produce = vi.fn(async () =>
|
||||||
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
|
||||||
|
);
|
||||||
|
const onRetryScheduled = vi.fn();
|
||||||
|
const onRetryFinished = vi.fn();
|
||||||
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryFinished });
|
||||||
|
expect(res.stopReason).toBe("error");
|
||||||
|
expect(produce).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRetryScheduled).not.toHaveBeenCalled();
|
||||||
|
expect(onRetryFinished).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a transient error up to maxRetries then returns the final error", async () => {
|
||||||
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
||||||
|
const onRetryScheduled = vi.fn();
|
||||||
|
const onRetryFinished = vi.fn();
|
||||||
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryFinished });
|
||||||
|
expect(res.stopReason).toBe("error");
|
||||||
|
expect(produce).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
|
||||||
|
expect(onRetryScheduled).toHaveBeenCalledTimes(3);
|
||||||
|
expect(onRetryFinished).toHaveBeenCalledWith(false, 3, "terminated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops retrying once a call succeeds", async () => {
|
||||||
|
let n = 0;
|
||||||
|
const produce = vi.fn(async () => {
|
||||||
|
n++;
|
||||||
|
return n < 3
|
||||||
|
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
|
||||||
|
: fauxAssistantMessage("recovered");
|
||||||
|
});
|
||||||
|
const onRetryFinished = vi.fn();
|
||||||
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryFinished });
|
||||||
|
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
|
||||||
|
expect(produce).toHaveBeenCalledTimes(3);
|
||||||
|
expect(onRetryFinished).toHaveBeenCalledWith(true, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an aborted retried call as unsuccessful", async () => {
|
||||||
|
let n = 0;
|
||||||
|
const produce = vi.fn(async () => {
|
||||||
|
n++;
|
||||||
|
return n === 1
|
||||||
|
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
|
||||||
|
: fauxAssistantMessage("", { stopReason: "aborted" });
|
||||||
|
});
|
||||||
|
const onRetryFinished = vi.fn();
|
||||||
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryFinished });
|
||||||
|
expect(res.stopReason).toBe("aborted");
|
||||||
|
expect(produce).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onRetryFinished).toHaveBeenCalledWith(false, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry when policy is disabled", async () => {
|
||||||
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
||||||
|
const onRetryScheduled = vi.fn();
|
||||||
|
const onRetryFinished = vi.fn();
|
||||||
|
const res = await retryAssistantCall(produce, disabled, undefined, { onRetryScheduled, onRetryFinished });
|
||||||
|
expect(res.stopReason).toBe("error");
|
||||||
|
expect(produce).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRetryScheduled).not.toHaveBeenCalled();
|
||||||
|
expect(onRetryFinished).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits onRetryAttemptStart after backoff before each retried call", async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
const produce = vi.fn(async () => {
|
||||||
|
events.push(`produce:${n}`);
|
||||||
|
n++;
|
||||||
|
return n < 3
|
||||||
|
? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })
|
||||||
|
: fauxAssistantMessage("recovered");
|
||||||
|
});
|
||||||
|
const onRetryScheduled = vi.fn((attempt: number) => {
|
||||||
|
events.push(`retry:${attempt}`);
|
||||||
|
});
|
||||||
|
const onRetryAttemptStart = vi.fn(() => {
|
||||||
|
events.push("attempt-start");
|
||||||
|
});
|
||||||
|
const res = await retryAssistantCall(produce, enabled, undefined, { onRetryScheduled, onRetryAttemptStart });
|
||||||
|
expect(res.content).toEqual([{ type: "text", text: "recovered" }]);
|
||||||
|
expect(onRetryScheduled).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onRetryAttemptStart).toHaveBeenCalledTimes(2);
|
||||||
|
expect(events).toEqual([
|
||||||
|
"produce:0",
|
||||||
|
"retry:1",
|
||||||
|
"attempt-start",
|
||||||
|
"produce:1",
|
||||||
|
"retry:2",
|
||||||
|
"attempt-start",
|
||||||
|
"produce:2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts backoff sleep via signal, returns an aborted message, and emits onRetryFinished(false)", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }));
|
||||||
|
const policy: RetryPolicy = { enabled: true, maxRetries: 5, baseDelayMs: 10_000 };
|
||||||
|
const onRetryFinished = vi.fn();
|
||||||
|
const p = retryAssistantCall(produce, policy, controller.signal, { onRetryFinished });
|
||||||
|
// Let one error call resolve and the first backoff sleep start, then abort.
|
||||||
|
await vi.waitFor(() => expect(produce).toHaveBeenCalled());
|
||||||
|
controller.abort();
|
||||||
|
const res = await p;
|
||||||
|
expect(res.stopReason).toBe("aborted");
|
||||||
|
expect(res.errorMessage).toBeUndefined();
|
||||||
|
expect(produce).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onRetryFinished).toHaveBeenCalledWith(false, 1, "terminated");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,15 +59,7 @@ describe("getSupportedThinkingLevels", () => {
|
|||||||
(modelId) => {
|
(modelId) => {
|
||||||
const model = getModel("openai", modelId);
|
const model = getModel("openai", modelId);
|
||||||
expect(model).toBeDefined();
|
expect(model).toBeDefined();
|
||||||
expect(getSupportedThinkingLevels(model!)).toEqual([
|
expect(getSupportedThinkingLevels(model!)).toEqual(["off", "low", "medium", "high", "xhigh", "max"]);
|
||||||
"off",
|
|
||||||
"minimal",
|
|
||||||
"low",
|
|
||||||
"medium",
|
|
||||||
"high",
|
|
||||||
"xhigh",
|
|
||||||
"max",
|
|
||||||
]);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -110,6 +102,12 @@ describe("getSupportedThinkingLevels", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["moonshotai", "moonshotai-cn"] as const)("uses the verified effort options for %s Kimi K3", (provider) => {
|
||||||
|
const model = getModel(provider, "kimi-k3");
|
||||||
|
expect(model).toBeDefined();
|
||||||
|
expect(getSupportedThinkingLevels(model!)).toEqual(["low", "high", "max"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes only low, high, max for Kimi Coding K3", () => {
|
it("includes only low, high, max for Kimi Coding K3", () => {
|
||||||
const model = getModel("kimi-coding", "k3");
|
const model = getModel("kimi-coding", "k3");
|
||||||
expect(model).toBeDefined();
|
expect(model).toBeDefined();
|
||||||
|
|||||||
@@ -44,7 +44,15 @@ describe("Together models", () => {
|
|||||||
|
|
||||||
it("models Together reasoning controls from the Together API surface", () => {
|
it("models Together reasoning controls from the Together API surface", () => {
|
||||||
const gptOss = getModel("together", "openai/gpt-oss-120b");
|
const gptOss = getModel("together", "openai/gpt-oss-120b");
|
||||||
expect(gptOss.thinkingLevelMap).toEqual({ off: null, minimal: null });
|
expect(gptOss.thinkingLevelMap).toEqual({
|
||||||
|
off: null,
|
||||||
|
minimal: null,
|
||||||
|
low: "low",
|
||||||
|
medium: "medium",
|
||||||
|
high: "high",
|
||||||
|
max: null,
|
||||||
|
xhigh: null,
|
||||||
|
});
|
||||||
expect(gptOss.compat).toMatchObject({
|
expect(gptOss.compat).toMatchObject({
|
||||||
supportsReasoningEffort: true,
|
supportsReasoningEffort: true,
|
||||||
thinkingFormat: "openai",
|
thinkingFormat: "openai",
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.1] - 2026-07-21
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Verifiable release source archives** — GitHub releases now include deterministic, checksummed source archives with instructions for rebuilding standalone binaries. See [Building standalone binaries from release source](../../README.md#building-standalone-binaries-from-release-source).
|
||||||
|
- **Resilient compaction and branch summaries** — Transient provider failures now follow the configured retry policy, with retry lifecycle events available to interactive, JSON, RPC, and SDK consumers. See [Compaction & Branch Summarization](docs/compaction.md) and [RPC retry events](docs/rpc.md#summarization_retry_scheduled--summarization_retry_attempt_start--summarization_retry_finished).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added deterministic, checksummed source archives to GitHub releases with documented standalone binary rebuild instructions ([#6913](https://github.com/earendil-works/pi/pull/6913) by [@christianklotz](https://github.com/christianklotz)).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed compaction and branch summarization to retry transient provider failures using the configured retry policy, with retry lifecycle events exposed to interactive, JSON, RPC, and SDK consumers ([#6901](https://github.com/earendil-works/pi/pull/6901) by [@davidbrai](https://github.com/davidbrai)).
|
||||||
|
- Fixed interactive startup waiting for background model catalog refresh while computing the footer provider count.
|
||||||
|
- Restored the default stream fallback for extensions using the pre-0.81 agent-core API ([#6915](https://github.com/earendil-works/pi/issues/6915)).
|
||||||
|
- Fixed inherited Kimi K3 models from Moonshot AI and Moonshot AI China to use the OpenAI thinking format and expose reasoning effort support.
|
||||||
|
|
||||||
## [0.81.0] - 2026-07-21
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ models: [{
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`.
|
Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`.
|
||||||
Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content.
|
Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user, assistant, or tool-result text content.
|
||||||
|
|
||||||
For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay.
|
For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay.
|
||||||
|
|
||||||
@@ -760,4 +760,4 @@ interface ProviderModelConfig {
|
|||||||
```
|
```
|
||||||
|
|
||||||
`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`.
|
`openrouter` sends `reasoning: { effort }`. `deepseek` sends `thinking: { type: "enabled" | "disabled" }` and `reasoning_effort` when enabled. `together` sends `reasoning: { enabled }` and also `reasoning_effort` when `supportsReasoningEffort` is enabled. `qwen` is for DashScope-style top-level `enable_thinking`. Use `qwen-chat-template` for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking` and need `preserve_thinking`. Use `chat-template` for configurable `chat_template_kwargs`, for example DeepSeek V3.x behind vLLM with `chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }`.
|
||||||
`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content.
|
`cacheControlFormat: "anthropic"` applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user, assistant, or tool-result text content.
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ type AgentSessionEvent =
|
|||||||
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
|
| { type: "compaction_start"; reason: "manual" | "threshold" | "overflow" }
|
||||||
| { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string }
|
| { type: "compaction_end"; reason: "manual" | "threshold" | "overflow"; result: CompactionResult | undefined; aborted: boolean; willRetry: boolean; errorMessage?: string }
|
||||||
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
||||||
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
|
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
||||||
|
| { type: "summarization_retry_scheduled"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
||||||
|
| { type: "summarization_retry_attempt_start"; source: "branchSummary" }
|
||||||
|
| { type: "summarization_retry_attempt_start"; source: "compaction"; reason: "manual" | "threshold" | "overflow" }
|
||||||
|
| { type: "summarization_retry_finished" };
|
||||||
```
|
```
|
||||||
|
|
||||||
`queue_update` emits the full pending steering and follow-up queues whenever they change. `compaction_start` and `compaction_end` cover both manual and automatic compaction.
|
`queue_update` emits the full pending steering and follow-up queues whenever they change. `compaction_start` and `compaction_end` cover both manual and automatic compaction.
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ For providers with partial OpenAI compatibility, use the `compat` field.
|
|||||||
| `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled |
|
| `requiresReasoningContentOnAssistantMessages` | Include empty `reasoning_content` on all replayed assistant messages when reasoning is enabled |
|
||||||
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |
|
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |
|
||||||
| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values |
|
| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values |
|
||||||
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. |
|
| `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`. |
|
| `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. |
|
| `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` | Include the `strict` field in tool definitions |
|
||||||
|
|||||||
@@ -851,6 +851,9 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
|
|||||||
| `compaction_end` | Compaction completes |
|
| `compaction_end` | Compaction completes |
|
||||||
| `auto_retry_start` | Auto-retry begins (after transient error) |
|
| `auto_retry_start` | Auto-retry begins (after transient error) |
|
||||||
| `auto_retry_end` | Auto-retry completes (success or final failure) |
|
| `auto_retry_end` | Auto-retry completes (success or final failure) |
|
||||||
|
| `summarization_retry_scheduled` | Retry scheduled for a transient compaction or branch-summary summarization error |
|
||||||
|
| `summarization_retry_attempt_start` | Retried summarization request starts |
|
||||||
|
| `summarization_retry_finished` | Summarization retry loop completes |
|
||||||
| `extension_error` | Extension threw an error |
|
| `extension_error` | Extension threw an error |
|
||||||
|
|
||||||
### agent_start
|
### agent_start
|
||||||
@@ -1077,6 +1080,36 @@ On final failure (max retries exceeded):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### summarization_retry_scheduled / summarization_retry_attempt_start / summarization_retry_finished
|
||||||
|
|
||||||
|
Emitted when compaction or branch-summary summarization retries after a transient provider error. These events use the same retry settings as automatic assistant-turn retries.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "summarization_retry_scheduled",
|
||||||
|
"attempt": 1,
|
||||||
|
"maxAttempts": 3,
|
||||||
|
"delayMs": 2000,
|
||||||
|
"errorMessage": "terminated"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "summarization_retry_attempt_start",
|
||||||
|
"source": "compaction",
|
||||||
|
"reason": "threshold"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For branch summaries, `source` is `"branchSummary"` and no `reason` is present.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "summarization_retry_finished"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### extension_error
|
### extension_error
|
||||||
|
|
||||||
Emitted when an extension throws an error.
|
Emitted when an extension throws an error.
|
||||||
|
|||||||
@@ -319,6 +319,9 @@ session.subscribe((event) => {
|
|||||||
case "compaction_end":
|
case "compaction_end":
|
||||||
case "auto_retry_start":
|
case "auto_retry_start":
|
||||||
case "auto_retry_end":
|
case "auto_retry_end":
|
||||||
|
case "summarization_retry_scheduled":
|
||||||
|
case "summarization_retry_attempt_start":
|
||||||
|
case "summarization_retry_finished":
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-custom-provider",
|
"name": "pi-extension-custom-provider",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-custom-provider",
|
"name": "pi-extension-custom-provider",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.52.0"
|
"@anthropic-ai/sdk": "^0.52.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-custom-provider-anthropic",
|
"name": "pi-extension-custom-provider-anthropic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-gondolin",
|
"name": "pi-extension-gondolin",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-gondolin",
|
"name": "pi-extension-gondolin",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/gondolin": "0.12.0"
|
"@earendil-works/gondolin": "0.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-gondolin",
|
"name": "pi-extension-gondolin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-sandbox",
|
"name": "pi-extension-sandbox",
|
||||||
"version": "1.11.0",
|
"version": "1.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-sandbox",
|
"name": "pi-extension-sandbox",
|
||||||
"version": "1.11.0",
|
"version": "1.11.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-sandbox",
|
"name": "pi-extension-sandbox",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.11.0",
|
"version": "1.11.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "echo 'nothing to clean'",
|
"clean": "echo 'nothing to clean'",
|
||||||
|
|||||||
+15
-15
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-coding-agent-install",
|
"name": "@earendil-works/pi-coding-agent-install",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@earendil-works/pi-coding-agent-install",
|
"name": "@earendil-works/pi-coding-agent-install",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "0.81.0"
|
"@earendil-works/pi-coding-agent": "0.81.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
@@ -450,11 +450,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-agent-core": {
|
"node_modules/@earendil-works/pi-agent-core": {
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"diff": "8.0.4",
|
"diff": "8.0.4",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
@@ -465,8 +465,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-ai": {
|
"node_modules/@earendil-works/pi-ai": {
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.91.1",
|
"@anthropic-ai/sdk": "0.91.1",
|
||||||
@@ -489,13 +489,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-coding-agent": {
|
"node_modules/@earendil-works/pi-coding-agent": {
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"@earendil-works/pi-tui": "^0.81.0",
|
"@earendil-works/pi-tui": "^0.81.1",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
@@ -523,8 +523,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-tui": {
|
"node_modules/@earendil-works/pi-tui": {
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"get-east-asian-width": "1.6.0",
|
"get-east-asian-width": "1.6.0",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-coding-agent-install",
|
"name": "@earendil-works/pi-coding-agent-install",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Lockfile root used by the Pi installer and updater.",
|
"description": "Lockfile root used by the Pi installer and updater.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "0.81.0"
|
"@earendil-works/pi-coding-agent": "0.81.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rimraf": "6.1.2",
|
"rimraf": "6.1.2",
|
||||||
|
|||||||
+12
-12
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-coding-agent",
|
"name": "@earendil-works/pi-coding-agent",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@earendil-works/pi-coding-agent",
|
"name": "@earendil-works/pi-coding-agent",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"@earendil-works/pi-tui": "^0.81.0",
|
"@earendil-works/pi-tui": "^0.81.1",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
@@ -474,11 +474,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-agent-core": {
|
"node_modules/@earendil-works/pi-agent-core": {
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"diff": "8.0.4",
|
"diff": "8.0.4",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"typebox": "1.1.38",
|
"typebox": "1.1.38",
|
||||||
@@ -489,8 +489,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-ai": {
|
"node_modules/@earendil-works/pi-ai": {
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "0.91.1",
|
"@anthropic-ai/sdk": "0.91.1",
|
||||||
@@ -513,8 +513,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@earendil-works/pi-tui": {
|
"node_modules/@earendil-works/pi-tui": {
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
|
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"get-east-asian-width": "1.6.0",
|
"get-east-asian-width": "1.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-coding-agent",
|
"name": "@earendil-works/pi-coding-agent",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"piConfig": {
|
"piConfig": {
|
||||||
@@ -39,9 +39,9 @@
|
|||||||
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
|
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"@earendil-works/pi-tui": "^0.81.0",
|
"@earendil-works/pi-tui": "^0.81.1",
|
||||||
"@silvia-odwyer/photon-node": "0.3.4",
|
"@silvia-odwyer/photon-node": "0.3.4",
|
||||||
"chalk": "5.6.2",
|
"chalk": "5.6.2",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
isContextOverflow,
|
isContextOverflow,
|
||||||
isRetryableAssistantError,
|
isRetryableAssistantError,
|
||||||
modelsAreEqual,
|
modelsAreEqual,
|
||||||
|
type RetryCallbacks,
|
||||||
resetApiProviders,
|
resetApiProviders,
|
||||||
streamSimple,
|
streamSimple,
|
||||||
} from "@earendil-works/pi-ai/compat";
|
} from "@earendil-works/pi-ai/compat";
|
||||||
@@ -161,7 +162,21 @@ export type AgentSessionEvent =
|
|||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
| { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
|
||||||
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
|
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
||||||
|
| {
|
||||||
|
type: "summarization_retry_scheduled";
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
delayMs: number;
|
||||||
|
errorMessage: string;
|
||||||
|
}
|
||||||
|
| { type: "summarization_retry_attempt_start"; source: "branchSummary" }
|
||||||
|
| {
|
||||||
|
type: "summarization_retry_attempt_start";
|
||||||
|
source: "compaction";
|
||||||
|
reason: "manual" | "threshold" | "overflow";
|
||||||
|
}
|
||||||
|
| { type: "summarization_retry_finished" };
|
||||||
|
|
||||||
/** Listener function for agent session events */
|
/** Listener function for agent session events */
|
||||||
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
||||||
@@ -1838,6 +1853,8 @@ export class AgentSession {
|
|||||||
this.thinkingLevel,
|
this.thinkingLevel,
|
||||||
this.agent.streamFunction,
|
this.agent.streamFunction,
|
||||||
env,
|
env,
|
||||||
|
this.settingsManager.getRetrySettings(),
|
||||||
|
this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }),
|
||||||
);
|
);
|
||||||
summary = result.summary;
|
summary = result.summary;
|
||||||
firstKeptEntryId = result.firstKeptEntryId;
|
firstKeptEntryId = result.firstKeptEntryId;
|
||||||
@@ -2114,6 +2131,8 @@ export class AgentSession {
|
|||||||
this.thinkingLevel,
|
this.thinkingLevel,
|
||||||
this.agent.streamFunction,
|
this.agent.streamFunction,
|
||||||
env,
|
env,
|
||||||
|
this.settingsManager.getRetrySettings(),
|
||||||
|
this._summarizationRetryCallbacks({ source: "compaction", reason }),
|
||||||
);
|
);
|
||||||
summary = compactResult.summary;
|
summary = compactResult.summary;
|
||||||
firstKeptEntryId = compactResult.firstKeptEntryId;
|
firstKeptEntryId = compactResult.firstKeptEntryId;
|
||||||
@@ -2620,6 +2639,37 @@ export class AgentSession {
|
|||||||
return isRetryableAssistantError(message);
|
return isRetryableAssistantError(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry policy + callbacks shared by compaction and branch-summary summarization calls.
|
||||||
|
* Uses the same `settings.retry` budget/backoff as agent-turn retries so a single transient
|
||||||
|
* stream drop no longer fails the whole operation. `source` carries the context
|
||||||
|
* the TUI needs to render the retry and recreate the underlying indicator.
|
||||||
|
*/
|
||||||
|
private _summarizationRetryCallbacks(
|
||||||
|
source: { source: "branchSummary" } | { source: "compaction"; reason: "manual" | "threshold" | "overflow" },
|
||||||
|
): RetryCallbacks {
|
||||||
|
return {
|
||||||
|
onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
|
||||||
|
this._emit({
|
||||||
|
type: "summarization_retry_scheduled",
|
||||||
|
attempt,
|
||||||
|
maxAttempts,
|
||||||
|
delayMs,
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRetryAttemptStart: () => {
|
||||||
|
this._emit({
|
||||||
|
type: "summarization_retry_attempt_start",
|
||||||
|
...source,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onRetryFinished: () => {
|
||||||
|
this._emit({ type: "summarization_retry_finished" });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepare a retryable error for continuation with exponential backoff.
|
* Prepare a retryable error for continuation with exponential backoff.
|
||||||
* @returns true if the caller should continue the agent, false otherwise
|
* @returns true if the caller should continue the agent, false otherwise
|
||||||
@@ -2934,6 +2984,8 @@ export class AgentSession {
|
|||||||
replaceInstructions,
|
replaceInstructions,
|
||||||
reserveTokens: branchSummarySettings.reserveTokens,
|
reserveTokens: branchSummarySettings.reserveTokens,
|
||||||
streamFn: this.agent.streamFunction,
|
streamFn: this.agent.streamFunction,
|
||||||
|
retry: this.settingsManager.getRetrySettings(),
|
||||||
|
callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),
|
||||||
});
|
});
|
||||||
if (result.aborted) {
|
if (result.aborted) {
|
||||||
return { cancelled: true, aborted: true };
|
return { cancelled: true, aborted: true };
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core";
|
import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core";
|
||||||
|
import type { RetryCallbacks, RetryPolicy } from "@earendil-works/pi-ai";
|
||||||
import { contentText } from "@earendil-works/pi-ai";
|
import { contentText } from "@earendil-works/pi-ai";
|
||||||
import type { Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
|
import type { Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
|
||||||
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
||||||
import {
|
import {
|
||||||
convertToLlm,
|
convertToLlm,
|
||||||
createBranchSummaryMessage,
|
createBranchSummaryMessage,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
createCustomMessage,
|
createCustomMessage,
|
||||||
} from "../messages.ts";
|
} from "../messages.ts";
|
||||||
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
|
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
|
||||||
import { estimateTokens } from "./compaction.ts";
|
import { completeSummarization, estimateTokens } from "./compaction.ts";
|
||||||
import {
|
import {
|
||||||
computeFileLists,
|
computeFileLists,
|
||||||
createFileOps,
|
createFileOps,
|
||||||
@@ -83,6 +83,10 @@ export interface GenerateBranchSummaryOptions {
|
|||||||
reserveTokens?: number;
|
reserveTokens?: number;
|
||||||
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
|
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
|
||||||
streamFn?: StreamFn;
|
streamFn?: StreamFn;
|
||||||
|
/** Retry policy for transient summarization errors. Reuses coding-agent's `settings.retry`. */
|
||||||
|
retry?: RetryPolicy;
|
||||||
|
/** Optional callbacks for retry reporting (e.g. TUI retry indicators). */
|
||||||
|
callbacks?: RetryCallbacks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -300,6 +304,8 @@ export async function generateBranchSummary(
|
|||||||
replaceInstructions,
|
replaceInstructions,
|
||||||
reserveTokens = 16384,
|
reserveTokens = 16384,
|
||||||
streamFn,
|
streamFn,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Token budget = context window minus reserved space for prompt + response
|
// Token budget = context window minus reserved space for prompt + response
|
||||||
@@ -338,12 +344,11 @@ export async function generateBranchSummary(
|
|||||||
|
|
||||||
// Call LLM for summarization. Prefer the session stream function so SDK
|
// Call LLM for summarization. Prefer the session stream function so SDK
|
||||||
// request behavior (timeouts, retries, attribution headers) stays consistent
|
// request behavior (timeouts, retries, attribution headers) stays consistent
|
||||||
// without running through agent state/events.
|
// without running through agent state/events. Retried via completeSummarization
|
||||||
|
// so transient stream drops reuse the configured retry policy.
|
||||||
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
|
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
|
||||||
const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
|
const requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
|
||||||
const response = streamFn
|
const response = await completeSummarization(model, context, requestOptions, streamFn, retry, callbacks);
|
||||||
? await (await streamFn(model, context, requestOptions)).result()
|
|
||||||
: await completeSimple(model, context, requestOptions);
|
|
||||||
|
|
||||||
// Check if aborted or errored
|
// Check if aborted or errored
|
||||||
if (response.stopReason === "aborted") {
|
if (response.stopReason === "aborted") {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { contentText } from "@earendil-works/pi-ai";
|
import { contentText, type RetryCallbacks, type RetryPolicy, retryAssistantCall } from "@earendil-works/pi-ai";
|
||||||
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
|
import type { AssistantMessage, Context, Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
|
||||||
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
||||||
import { convertToLlm } from "../messages.ts";
|
import { convertToLlm } from "../messages.ts";
|
||||||
@@ -552,17 +552,24 @@ function createSummarizationOptions(
|
|||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeSummarization(
|
/**
|
||||||
|
* Shared choke point for every compaction/branch-summary summarization call. Wraps the
|
||||||
|
* single LLM call in {@link retryAssistantCall} so transient stream drops (e.g.
|
||||||
|
* `terminated`, socket close) honor the configured retry policy instead of failing
|
||||||
|
* the whole compaction on the first attempt. Deterministic errors and aborts return
|
||||||
|
* immediately (see {@link retryAssistantCall}).
|
||||||
|
*/
|
||||||
|
export async function completeSummarization(
|
||||||
model: Model<any>,
|
model: Model<any>,
|
||||||
context: Context,
|
context: Context,
|
||||||
options: SimpleStreamOptions,
|
options: SimpleStreamOptions,
|
||||||
streamFn?: StreamFn,
|
streamFn?: StreamFn,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<AssistantMessage> {
|
): Promise<AssistantMessage> {
|
||||||
if (!streamFn) {
|
const produce = async (): Promise<AssistantMessage> =>
|
||||||
return completeSimple(model, context, options);
|
streamFn ? (await streamFn(model, context, options)).result() : completeSimple(model, context, options);
|
||||||
}
|
return retryAssistantCall(produce, retry, options.signal, callbacks);
|
||||||
const stream = await streamFn(model, context, options);
|
|
||||||
return stream.result();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -581,6 +588,8 @@ export async function generateSummary(
|
|||||||
thinkingLevel?: ThinkingLevel,
|
thinkingLevel?: ThinkingLevel,
|
||||||
streamFn?: StreamFn,
|
streamFn?: StreamFn,
|
||||||
env?: Record<string, string>,
|
env?: Record<string, string>,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return (
|
return (
|
||||||
await generateSummaryWithUsage(
|
await generateSummaryWithUsage(
|
||||||
@@ -595,6 +604,8 @@ export async function generateSummary(
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
streamFn,
|
streamFn,
|
||||||
env,
|
env,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
)
|
)
|
||||||
).text;
|
).text;
|
||||||
}
|
}
|
||||||
@@ -612,6 +623,8 @@ export async function generateSummaryWithUsage(
|
|||||||
thinkingLevel?: ThinkingLevel,
|
thinkingLevel?: ThinkingLevel,
|
||||||
streamFn?: StreamFn,
|
streamFn?: StreamFn,
|
||||||
env?: Record<string, string>,
|
env?: Record<string, string>,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<{ text: string; usage: Usage }> {
|
): Promise<{ text: string; usage: Usage }> {
|
||||||
const maxTokens = Math.min(
|
const maxTokens = Math.min(
|
||||||
Math.floor(0.8 * reserveTokens),
|
Math.floor(0.8 * reserveTokens),
|
||||||
@@ -651,6 +664,8 @@ export async function generateSummaryWithUsage(
|
|||||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||||
completionOptions,
|
completionOptions,
|
||||||
streamFn,
|
streamFn,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.stopReason === "error") {
|
if (response.stopReason === "error") {
|
||||||
@@ -801,6 +816,8 @@ export async function compact(
|
|||||||
thinkingLevel?: ThinkingLevel,
|
thinkingLevel?: ThinkingLevel,
|
||||||
streamFn?: StreamFn,
|
streamFn?: StreamFn,
|
||||||
env?: Record<string, string>,
|
env?: Record<string, string>,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<CompactionResult> {
|
): Promise<CompactionResult> {
|
||||||
const {
|
const {
|
||||||
firstKeptEntryId,
|
firstKeptEntryId,
|
||||||
@@ -833,6 +850,8 @@ export async function compact(
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
streamFn,
|
streamFn,
|
||||||
env,
|
env,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
historyText = historyResult.text;
|
historyText = historyResult.text;
|
||||||
historyUsage = historyResult.usage;
|
historyUsage = historyResult.usage;
|
||||||
@@ -847,6 +866,8 @@ export async function compact(
|
|||||||
signal,
|
signal,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
streamFn,
|
streamFn,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
// Merge into single summary
|
// Merge into single summary
|
||||||
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;
|
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;
|
||||||
@@ -865,6 +886,8 @@ export async function compact(
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
streamFn,
|
streamFn,
|
||||||
env,
|
env,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
summary = result.text;
|
summary = result.text;
|
||||||
summaryUsage = result.usage;
|
summaryUsage = result.usage;
|
||||||
@@ -900,6 +923,8 @@ async function generateTurnPrefixSummary(
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
thinkingLevel?: ThinkingLevel,
|
thinkingLevel?: ThinkingLevel,
|
||||||
streamFn?: StreamFn,
|
streamFn?: StreamFn,
|
||||||
|
retry?: RetryPolicy,
|
||||||
|
callbacks?: RetryCallbacks,
|
||||||
): Promise<{ text: string; usage: Usage }> {
|
): Promise<{ text: string; usage: Usage }> {
|
||||||
const maxTokens = Math.min(
|
const maxTokens = Math.min(
|
||||||
Math.floor(0.5 * reserveTokens),
|
Math.floor(0.5 * reserveTokens),
|
||||||
@@ -921,6 +946,8 @@ async function generateTurnPrefixSummary(
|
|||||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||||
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
|
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
|
||||||
streamFn,
|
streamFn,
|
||||||
|
retry,
|
||||||
|
callbacks,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.stopReason === "error") {
|
if (response.stopReason === "error") {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { clampThinkingLevel, type Message, type Model } from "@earendil-works/pi-ai/compat";
|
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat";
|
||||||
import { getAgentDir } from "../config.ts";
|
import { getAgentDir } from "../config.ts";
|
||||||
import { resolvePath } from "../utils/paths.ts";
|
import { resolvePath } from "../utils/paths.ts";
|
||||||
import { AgentSession } from "./agent-session.ts";
|
import { AgentSession } from "./agent-session.ts";
|
||||||
@@ -30,6 +30,11 @@ import {
|
|||||||
withFileMutationQueue,
|
withFileMutationQueue,
|
||||||
} from "./tools/index.ts";
|
} from "./tools/index.ts";
|
||||||
|
|
||||||
|
// Preserve the pre-0.81 fallback for extensions that construct Agent instances
|
||||||
|
// or invoke low-level agent loops without supplying streamFn. Agent core remains
|
||||||
|
// provider-agnostic and does not import pi-ai/compat itself.
|
||||||
|
setDefaultStreamFn(streamSimple);
|
||||||
|
|
||||||
export interface CreateAgentSessionOptions {
|
export interface CreateAgentSessionOptions {
|
||||||
/** Working directory for project-local discovery. Default: process.cwd() */
|
/** Working directory for project-local discovery. Default: process.cwd() */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -294,7 +299,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
convertToLlm: convertToLlmWithBlockImages,
|
convertToLlm: convertToLlmWithBlockImages,
|
||||||
streamFunction: async (model, context, options) => {
|
streamFn: async (model, context, options) => {
|
||||||
const providerRetrySettings = settingsManager.getProviderRetrySettings();
|
const providerRetrySettings = settingsManager.getProviderRetrySettings();
|
||||||
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
|
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
|
||||||
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
|
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
|
||||||
|
|||||||
@@ -808,7 +808,8 @@ export async function main(args: string[], options?: MainOptions) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!offlineMode && (appMode === "interactive" || appMode === "rpc")) {
|
// RPC refreshes catalogs here in the background; interactive mode starts its refresh after TUI initialization.
|
||||||
|
if (!offlineMode && appMode === "rpc") {
|
||||||
void modelRuntime.refresh().catch(() => {});
|
void modelRuntime.refresh().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -826,6 +826,13 @@ export class InteractiveMode {
|
|||||||
async run(): Promise<void> {
|
async run(): Promise<void> {
|
||||||
await this.init();
|
await this.init();
|
||||||
|
|
||||||
|
if (!process.env.PI_OFFLINE) {
|
||||||
|
void this.session.modelRuntime
|
||||||
|
.refresh()
|
||||||
|
.then(() => this.updateAvailableProviderCount())
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Start version check asynchronously
|
// Start version check asynchronously
|
||||||
checkForNewPiVersion(this.version).then((newRelease) => {
|
checkForNewPiVersion(this.version).then((newRelease) => {
|
||||||
if (newRelease) {
|
if (newRelease) {
|
||||||
@@ -3110,6 +3117,32 @@ export class InteractiveMode {
|
|||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "summarization_retry_scheduled": {
|
||||||
|
this.showError(event.errorMessage);
|
||||||
|
this.showStatusIndicator(
|
||||||
|
new RetryStatusIndicator(this.ui, event.attempt, event.maxAttempts, event.delayMs),
|
||||||
|
);
|
||||||
|
this.ui.requestRender();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "summarization_retry_attempt_start": {
|
||||||
|
this.clearStatusIndicator("retry");
|
||||||
|
if (event.source === "branchSummary") {
|
||||||
|
this.showStatusIndicator(new BranchSummaryStatusIndicator(this.ui));
|
||||||
|
} else {
|
||||||
|
this.showStatusIndicator(new CompactionStatusIndicator(this.ui, event.reason));
|
||||||
|
}
|
||||||
|
this.ui.requestRender();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "summarization_retry_finished": {
|
||||||
|
this.clearStatusIndicator("retry");
|
||||||
|
this.ui.requestRender();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4326,10 +4359,13 @@ export class InteractiveMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update the footer's available provider count from current model candidates */
|
/** Update the footer's available provider count from the current snapshot without refreshing catalogs. */
|
||||||
private async updateAvailableProviderCount(): Promise<void> {
|
private updateAvailableProviderCount(): void {
|
||||||
const models = await this.getModelCandidates();
|
const models =
|
||||||
const uniqueProviders = new Set(models.map((m) => m.provider));
|
this.session.scopedModels.length > 0
|
||||||
|
? this.session.scopedModels.map((scoped) => scoped.model)
|
||||||
|
: this.session.modelRuntime.getAvailableSnapshot();
|
||||||
|
const uniqueProviders = new Set(models.map((model) => model.provider));
|
||||||
this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size);
|
this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("AgentSession auto-compaction queue resume", () => {
|
|||||||
|
|
||||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
model,
|
model,
|
||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
|||||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
getApiKey: () => API_KEY,
|
getApiKey: () => API_KEY,
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
model,
|
model,
|
||||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
streamFunction: (_model, _context, options) => {
|
streamFn: (_model, _context, options) => {
|
||||||
abortSignal = options?.signal;
|
abortSignal = options?.signal;
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -195,7 +195,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
streamFunction: (_model, context, options) => {
|
streamFn: (_model, context, options) => {
|
||||||
abortSignal = options?.signal;
|
abortSignal = options?.signal;
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -301,7 +301,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||||
@@ -362,7 +362,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
tools: [tool],
|
tools: [tool],
|
||||||
},
|
},
|
||||||
streamFunction: async (_model, context) => {
|
streamFn: async (_model, context) => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length;
|
const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length;
|
||||||
@@ -508,7 +508,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
|||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
tools: [tool],
|
tools: [tool],
|
||||||
},
|
},
|
||||||
streamFunction: async (_model, context) => {
|
streamFn: async (_model, context) => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
const hasToolResult = context.messages.some((message) => message.role === "toolResult");
|
const hasToolResult = context.messages.some((message) => message.role === "toolResult");
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ describe("AgentSession retry", () => {
|
|||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
getApiKey: () => "test-key",
|
getApiKey: () => "test-key",
|
||||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
callCount++;
|
callCount++;
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
@@ -203,7 +203,7 @@ describe("AgentSession retry", () => {
|
|||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
getApiKey: () => "test-key",
|
getApiKey: () => "test-key",
|
||||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||||
streamFunction: streamFn,
|
streamFn: streamFn,
|
||||||
});
|
});
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||||
@@ -255,7 +255,7 @@ describe("AgentSession retry", () => {
|
|||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
getApiKey: () => "test-key",
|
getApiKey: () => "test-key",
|
||||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||||
streamFunction: () => {
|
streamFn: () => {
|
||||||
callCount++;
|
callCount++;
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function createSession() {
|
|||||||
const session = new AgentSession({
|
const session = new AgentSession({
|
||||||
agent: new Agent({
|
agent: new Agent({
|
||||||
getApiKey: () => "test-key",
|
getApiKey: () => "test-key",
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
model,
|
model,
|
||||||
systemPrompt: "You are a helpful assistant.",
|
systemPrompt: "You are a helpful assistant.",
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ describe.skipIf(!API_KEY)("Compaction extensions", () => {
|
|||||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
getApiKey: () => API_KEY,
|
getApiKey: () => API_KEY,
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
model,
|
model,
|
||||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||||
|
|||||||
@@ -652,7 +652,7 @@ describe("ModelRegistry", () => {
|
|||||||
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
|
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
|
||||||
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true);
|
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true);
|
||||||
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
||||||
});
|
}, 60_000);
|
||||||
|
|
||||||
test("removing custom models from models.json keeps built-in provider models", async () => {
|
test("removing custom models from models.json keeps built-in provider models", async () => {
|
||||||
writeModelsJson({
|
writeModelsJson({
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs:
|
|||||||
systemPrompt: "Test",
|
systemPrompt: "Test",
|
||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
streamFunction: (_model, _context, _options) => {
|
streamFn: (_model, _context, _options) => {
|
||||||
const stream = new MockAssistantStream();
|
const stream = new MockAssistantStream();
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
|||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
|
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
initialState: {
|
initialState: {
|
||||||
model,
|
model,
|
||||||
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe("regression #5596: missing configured theme export", () => {
|
|||||||
tools: [],
|
tools: [],
|
||||||
},
|
},
|
||||||
convertToLlm,
|
convertToLlm,
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
});
|
});
|
||||||
const session = new AgentSession({
|
const session = new AgentSession({
|
||||||
agent,
|
agent,
|
||||||
|
|||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
import type { StreamFn } from "@earendil-works/pi-agent-core";
|
||||||
|
import { type AssistantMessage, createAssistantMessageEventStream, fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createHarness, type Harness } from "../harness.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for #6647: compaction runs a single non-retried summarization call, so a
|
||||||
|
* transient mid-stream socket death (`terminated`) failed the whole compaction.
|
||||||
|
* Verifies that summarization now reuses `settings.retry` (bounded retries with
|
||||||
|
* exponential backoff gated on isRetryableAssistantError), emits
|
||||||
|
* `summarization_retry_*` events, and that aborts / non-retryable errors are not retried.
|
||||||
|
*/
|
||||||
|
describe("#6647 compaction retries transient summarization failures", () => {
|
||||||
|
const harnesses: Harness[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (harnesses.length > 0) {
|
||||||
|
harnesses.pop()?.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createUsage(totalTokens: number) {
|
||||||
|
return {
|
||||||
|
input: totalTokens,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedCompactableSession(harness: Harness): void {
|
||||||
|
harness.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } });
|
||||||
|
const now = Date.now();
|
||||||
|
harness.sessionManager.appendMessage({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "message to compact" }],
|
||||||
|
timestamp: now - 1000,
|
||||||
|
});
|
||||||
|
const model = harness.getModel();
|
||||||
|
const assistant: AssistantMessage = {
|
||||||
|
...fauxAssistantMessage("", { stopReason: "stop", timestamp: now - 500 }),
|
||||||
|
api: model.api,
|
||||||
|
provider: model.provider,
|
||||||
|
model: model.id,
|
||||||
|
usage: createUsage(100),
|
||||||
|
};
|
||||||
|
assistant.content = [{ type: "text", text: "assistant response to compact" }];
|
||||||
|
harness.sessionManager.appendMessage(assistant);
|
||||||
|
harness.session.agent.state.messages = harness.sessionManager.buildSessionContext().messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** streamFn that responds with the given sequence of assistant messages across calls. */
|
||||||
|
function useScriptedStreamFn(harness: Harness, script: AssistantMessage[]): () => number {
|
||||||
|
let callCount = 0;
|
||||||
|
const streamFunction: StreamFn = (model) => {
|
||||||
|
const message = script[callCount] ?? script[script.length - 1]!;
|
||||||
|
callCount++;
|
||||||
|
const stream = createAssistantMessageEventStream();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
||||||
|
stream.push({
|
||||||
|
type: "error",
|
||||||
|
reason: message.stopReason,
|
||||||
|
error: { ...message, api: model.api, provider: model.provider, model: model.id },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stream.push({
|
||||||
|
type: "done",
|
||||||
|
reason: message.stopReason,
|
||||||
|
message: { ...message, api: model.api, provider: model.provider, model: model.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
harness.session.agent.streamFunction = streamFunction;
|
||||||
|
return () => callCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("retries a transient `terminated` summarization error and compacts successfully", async () => {
|
||||||
|
const harness = await createHarness({ withConfiguredAuth: false });
|
||||||
|
harnesses.push(harness);
|
||||||
|
seedCompactableSession(harness);
|
||||||
|
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 } });
|
||||||
|
|
||||||
|
const model = harness.getModel();
|
||||||
|
const error = (errorMessage: string): AssistantMessage => ({
|
||||||
|
...fauxAssistantMessage("", { stopReason: "error", errorMessage }),
|
||||||
|
usage: createUsage(10),
|
||||||
|
});
|
||||||
|
const success: AssistantMessage = {
|
||||||
|
...fauxAssistantMessage("recovered summary"),
|
||||||
|
usage: createUsage(10),
|
||||||
|
};
|
||||||
|
const getCallCount = useScriptedStreamFn(harness, [error("terminated"), error("terminated"), success]);
|
||||||
|
|
||||||
|
const result = await harness.session.compact();
|
||||||
|
|
||||||
|
expect(result.summary).toContain("recovered summary");
|
||||||
|
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
|
||||||
|
const starts = harness.eventsOfType("summarization_retry_scheduled");
|
||||||
|
const ends = harness.eventsOfType("summarization_retry_finished");
|
||||||
|
expect(starts).toHaveLength(2);
|
||||||
|
expect(ends).toHaveLength(1);
|
||||||
|
expect(starts[0]).toMatchObject({ attempt: 1, maxAttempts: 3, errorMessage: "terminated" });
|
||||||
|
expect(starts[1]).toMatchObject({ attempt: 2, maxAttempts: 3 });
|
||||||
|
expect(ends[0]).toMatchObject({ type: "summarization_retry_finished" });
|
||||||
|
// model.* referenced to keep imports honest
|
||||||
|
expect(model.id).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry a non-retryable error (insufficient_quota)", async () => {
|
||||||
|
const harness = await createHarness({ withConfiguredAuth: false });
|
||||||
|
harnesses.push(harness);
|
||||||
|
seedCompactableSession(harness);
|
||||||
|
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 3, baseDelayMs: 0 } });
|
||||||
|
|
||||||
|
const error: AssistantMessage = {
|
||||||
|
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "insufficient_quota" }),
|
||||||
|
usage: createUsage(10),
|
||||||
|
};
|
||||||
|
const getCallCount = useScriptedStreamFn(harness, [error]);
|
||||||
|
|
||||||
|
await expect(harness.session.compact()).rejects.toThrow("insufficient_quota");
|
||||||
|
expect(getCallCount()).toBe(1);
|
||||||
|
expect(harness.eventsOfType("summarization_retry_scheduled")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry when retry is disabled", async () => {
|
||||||
|
const harness = await createHarness({ withConfiguredAuth: false });
|
||||||
|
harnesses.push(harness);
|
||||||
|
seedCompactableSession(harness);
|
||||||
|
harness.settingsManager.applyOverrides({ retry: { enabled: false, maxRetries: 3, baseDelayMs: 0 } });
|
||||||
|
|
||||||
|
const error: AssistantMessage = {
|
||||||
|
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
|
||||||
|
usage: createUsage(10),
|
||||||
|
};
|
||||||
|
const getCallCount = useScriptedStreamFn(harness, [error]);
|
||||||
|
|
||||||
|
await expect(harness.session.compact()).rejects.toThrow("terminated");
|
||||||
|
expect(getCallCount()).toBe(1);
|
||||||
|
expect(harness.eventsOfType("summarization_retry_scheduled")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops retrying after maxRetries and reports failure", async () => {
|
||||||
|
const harness = await createHarness({ withConfiguredAuth: false });
|
||||||
|
harnesses.push(harness);
|
||||||
|
seedCompactableSession(harness);
|
||||||
|
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 2, baseDelayMs: 0 } });
|
||||||
|
|
||||||
|
const error: AssistantMessage = {
|
||||||
|
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
|
||||||
|
usage: createUsage(10),
|
||||||
|
};
|
||||||
|
const getCallCount = useScriptedStreamFn(harness, [error, error, error]);
|
||||||
|
|
||||||
|
await expect(harness.session.compact()).rejects.toThrow("terminated");
|
||||||
|
expect(getCallCount()).toBe(3); // 1 initial + 2 retries
|
||||||
|
const starts = harness.eventsOfType("summarization_retry_scheduled");
|
||||||
|
const ends = harness.eventsOfType("summarization_retry_finished");
|
||||||
|
expect(starts).toHaveLength(2);
|
||||||
|
expect(ends).toHaveLength(1);
|
||||||
|
expect(ends[0]).toMatchObject({ type: "summarization_retry_finished" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts an in-flight retry backoff via abortCompaction", async () => {
|
||||||
|
const harness = await createHarness({ withConfiguredAuth: false });
|
||||||
|
harnesses.push(harness);
|
||||||
|
seedCompactableSession(harness);
|
||||||
|
harness.settingsManager.applyOverrides({ retry: { enabled: true, maxRetries: 5, baseDelayMs: 30_000 } });
|
||||||
|
|
||||||
|
const error: AssistantMessage = {
|
||||||
|
...fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }),
|
||||||
|
usage: createUsage(10),
|
||||||
|
};
|
||||||
|
useScriptedStreamFn(harness, [error, error, error]);
|
||||||
|
|
||||||
|
const compactPromise = harness.session.compact();
|
||||||
|
// Let the first error resolve and the retry backoff sleep start.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
harness.session.abortCompaction();
|
||||||
|
|
||||||
|
// The aborted retry backoff is normalized to an aborted assistant message,
|
||||||
|
// which compaction classifies as aborted.
|
||||||
|
await expect(compactPromise).rejects.toThrow();
|
||||||
|
const compactionEnd = harness.eventsOfType("compaction_end").at(-1);
|
||||||
|
expect(compactionEnd).toMatchObject({ aborted: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createHarness } from "../harness.ts";
|
||||||
|
|
||||||
|
const wrappedDnsLookupError =
|
||||||
|
"The pending stream has been canceled (caused by: getaddrinfo ENOTFOUND bedrock-runtime.us-east-1.amazonaws.com)";
|
||||||
|
|
||||||
|
describe("issue #6904 DNS transport failure retry", () => {
|
||||||
|
it("retries a transient DNS lookup failure", async () => {
|
||||||
|
const harness = await createHarness({ settings: { retry: { enabled: true, maxRetries: 3, baseDelayMs: 1 } } });
|
||||||
|
try {
|
||||||
|
harness.setResponses([
|
||||||
|
fauxAssistantMessage("", { stopReason: "error", errorMessage: wrappedDnsLookupError }),
|
||||||
|
fauxAssistantMessage("recovered after DNS retry"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await harness.session.prompt("test");
|
||||||
|
|
||||||
|
expect(harness.faux.state.callCount).toBe(2);
|
||||||
|
expect(harness.eventsOfType("auto_retry_start").map((event) => event.errorMessage)).toEqual([
|
||||||
|
wrappedDnsLookupError,
|
||||||
|
]);
|
||||||
|
expect(harness.eventsOfType("auto_retry_end").map((event) => event.success)).toEqual([true]);
|
||||||
|
} finally {
|
||||||
|
harness.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -378,7 +378,7 @@ async function createHarnessWithResourceLoader(
|
|||||||
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
||||||
tools: options.tools ?? [],
|
tools: options.tools ?? [],
|
||||||
},
|
},
|
||||||
streamFunction: streamFn,
|
streamFn: streamFn,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionManager = SessionManager.inMemory();
|
const sessionManager = SessionManager.inMemory();
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi
|
|||||||
systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.",
|
systemPrompt: options.systemPrompt ?? "You are a helpful assistant. Be extremely concise.",
|
||||||
tools: createCodingTools(process.cwd()),
|
tools: createCodingTools(process.cwd()),
|
||||||
},
|
},
|
||||||
streamFunction: streamSimple,
|
streamFn: streamSimple,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
|
const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.1] - 2026-07-21
|
||||||
|
|
||||||
## [0.81.0] - 2026-07-21
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-server",
|
"name": "@earendil-works/pi-server",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"description": "experimental server package for pi",
|
"description": "experimental server package for pi",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "^0.81.0"
|
"@earendil-works/pi-coding-agent": "^0.81.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"shx": "0.4.0"
|
"shx": "0.4.0"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.1] - 2026-07-21
|
||||||
|
|
||||||
## [0.81.0] - 2026-07-21
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-storage-sqlite-node",
|
"name": "@earendil-works/pi-storage-sqlite-node",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"description": "Node sqlite storage backend for @earendil-works/pi-agent-core sessions",
|
"description": "Node sqlite storage backend for @earendil-works/pi-agent-core sessions",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.81.0",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
"@earendil-works/pi-agent-core": "^0.81.0"
|
"@earendil-works/pi-agent-core": "^0.81.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.81.1] - 2026-07-21
|
||||||
|
|
||||||
## [0.81.0] - 2026-07-21
|
## [0.81.0] - 2026-07-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@earendil-works/pi-tui",
|
"name": "@earendil-works/pi-tui",
|
||||||
"version": "0.81.0",
|
"version": "0.81.1",
|
||||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ if (!model) throw new Error("Anthropic smoke-test model not found");
|
|||||||
|
|
||||||
export const agent = new Agent({
|
export const agent = new Agent({
|
||||||
initialState: { model },
|
initialState: { model },
|
||||||
streamFunction: models.streamSimple.bind(models),
|
streamFn: models.streamSimple.bind(models),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const model = getModel("google", "gemini-2.5-flash");
|
|||||||
const schema = Type.Object({ prompt: Type.String() });
|
const schema = Type.Object({ prompt: Type.String() });
|
||||||
const stream = createAssistantMessageEventStream();
|
const stream = createAssistantMessageEventStream();
|
||||||
|
|
||||||
const agent = new Agent({ initialState: { model }, streamFunction: streamSimple });
|
const agent = new Agent({ initialState: { model }, streamFn: streamSimple });
|
||||||
agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 });
|
agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 });
|
||||||
const repo = new InMemorySessionRepo();
|
const repo = new InMemorySessionRepo();
|
||||||
const result = getOrThrow(ok({ value: 1 }));
|
const result = getOrThrow(ok({ value: 1 }));
|
||||||
|
|||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create the deterministic source archive uploaded with GitHub releases.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/create-source-archive.sh --version <version> --ref <git-ref> --out <archive.tar.gz>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
version=""
|
||||||
|
source_ref="HEAD"
|
||||||
|
output=""
|
||||||
|
invocation_dir="$PWD"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 --version <version> [--ref <git-ref>] --out <archive.tar.gz>"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_value() {
|
||||||
|
if [[ $# -lt 2 || -z "$2" ]]; then
|
||||||
|
echo "$1 requires a value" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
require_value "$@"
|
||||||
|
version="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ref)
|
||||||
|
require_value "$@"
|
||||||
|
source_ref="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--out)
|
||||||
|
require_value "$@"
|
||||||
|
output="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$version" || -z "$output" ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$version" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then
|
||||||
|
echo "Invalid version: $version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
commit="$(git rev-parse --verify --end-of-options "${source_ref}^{commit}")"
|
||||||
|
|
||||||
|
package_version="$(git show "${commit}:packages/coding-agent/package.json" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version')"
|
||||||
|
if [[ "$package_version" != "$version" ]]; then
|
||||||
|
echo "Version ${version} does not match package version ${package_version} at ${source_ref}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$output" != /* ]]; then
|
||||||
|
output="$invocation_dir/$output"
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$output")"
|
||||||
|
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
|
||||||
|
|
||||||
|
temporary_archive="$(mktemp "${output}.tmp.XXXXXX")"
|
||||||
|
manifest="$(mktemp "${output}.manifest.XXXXXX")"
|
||||||
|
trap 'rm -f "$temporary_archive" "$manifest"' EXIT
|
||||||
|
|
||||||
|
archive_root="pi-${version}"
|
||||||
|
git archive --format=tar --prefix="${archive_root}/" "$commit" | gzip -n -9 > "$temporary_archive"
|
||||||
|
tar -tzf "$temporary_archive" > "$manifest"
|
||||||
|
|
||||||
|
required_paths=(
|
||||||
|
"package.json"
|
||||||
|
"package-lock.json"
|
||||||
|
"scripts/build-binaries.sh"
|
||||||
|
"packages/coding-agent/package.json"
|
||||||
|
"packages/coding-agent/src/utils/image-resize-worker.ts"
|
||||||
|
"packages/coding-agent/src/core/export-html/template.css"
|
||||||
|
)
|
||||||
|
|
||||||
|
for path in "${required_paths[@]}"; do
|
||||||
|
if ! grep -Fxq "${archive_root}/${path}" "$manifest"; then
|
||||||
|
echo "Source archive is missing required path: $path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! awk -v prefix="${archive_root}/" 'index($0, prefix) != 1 { exit 1 }' "$manifest"; then
|
||||||
|
echo "Source archive contains a path outside ${archive_root}/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -Eq '(^|/)node_modules/|(^|/)packages/coding-agent/binaries/' "$manifest"; then
|
||||||
|
echo "Source archive contains generated dependencies or binaries" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mv "$temporary_archive" "$output"
|
||||||
|
trap 'rm -f "$manifest"' EXIT
|
||||||
|
printf '%s\n' "$output"
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
|
||||||
function printUsage() {
|
function printUsage() {
|
||||||
console.log(`Usage: node scripts/diff-model-catalog.mjs [provider ...]
|
console.log(`Usage: node scripts/diff-model-catalog.mjs [--thinking] [provider ...]
|
||||||
|
|
||||||
Generates the model catalog at HEAD and in the current worktree, then shows
|
Generates the model catalog at HEAD and in the current worktree, then shows
|
||||||
JSON differences. If providers are omitted, all providers are compared.
|
JSON differences. If providers are omitted, all providers are compared.
|
||||||
|
|
||||||
|
--thinking compares each worktree's effective thinking levels using that
|
||||||
|
worktree's getSupportedThinkingLevels() implementation.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
node scripts/diff-model-catalog.mjs github-copilot
|
node scripts/diff-model-catalog.mjs github-copilot
|
||||||
npm run diff:model-catalog -- github-copilot
|
npm run diff:model-catalog -- --thinking moonshotai kimi-coding
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +46,9 @@ if (args.includes("--help")) {
|
|||||||
printUsage();
|
printUsage();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (args.some((arg) => arg.startsWith("-"))) {
|
const thinkingOnly = args.includes("--thinking");
|
||||||
|
const requestedProviders = args.filter((arg) => arg !== "--thinking");
|
||||||
|
if (requestedProviders.some((arg) => arg.startsWith("-"))) {
|
||||||
printUsage();
|
printUsage();
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,8 @@ const temporaryRoot = mkdtempSync(join(tmpdir(), "pi-model-catalog-diff-"));
|
|||||||
const baselineWorktree = join(temporaryRoot, "baseline-worktree");
|
const baselineWorktree = join(temporaryRoot, "baseline-worktree");
|
||||||
const baselineOutput = join(temporaryRoot, "before");
|
const baselineOutput = join(temporaryRoot, "before");
|
||||||
const currentOutput = join(temporaryRoot, "after");
|
const currentOutput = join(temporaryRoot, "after");
|
||||||
|
const baselineThinkingOutput = join(temporaryRoot, "before-thinking");
|
||||||
|
const currentThinkingOutput = join(temporaryRoot, "after-thinking");
|
||||||
let worktreeAdded = false;
|
let worktreeAdded = false;
|
||||||
|
|
||||||
function generateCatalog(cwd, outputDir, pretty = false) {
|
function generateCatalog(cwd, outputDir, pretty = false) {
|
||||||
@@ -76,8 +83,54 @@ function readProviderCatalog(outputDir, provider) {
|
|||||||
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined;
|
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateThinkingCatalog(cwd, catalogPath, outputDir) {
|
||||||
|
run(process.execPath, ["scripts/generate-thinking-capabilities.mjs", catalogPath, outputDir], {
|
||||||
|
cwd,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const THINKING_LEVEL_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||||||
|
const THINKING_LEVEL_RANKS = new Map(THINKING_LEVEL_ORDER.map((key, index) => [key, index]));
|
||||||
|
|
||||||
|
function sortJsonKeys(keys, parentKey) {
|
||||||
|
if (parentKey !== "thinkingLevelMap" && parentKey !== "values") return keys.sort();
|
||||||
|
return keys.sort((left, right) => {
|
||||||
|
const leftRank = THINKING_LEVEL_RANKS.get(left) ?? Number.POSITIVE_INFINITY;
|
||||||
|
const rightRank = THINKING_LEVEL_RANKS.get(right) ?? Number.POSITIVE_INFINITY;
|
||||||
|
return leftRank - rightRank || left.localeCompare(right);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizeJson(value, parentKey) {
|
||||||
|
if (Array.isArray(value)) return value.map((entry) => canonicalizeJson(entry));
|
||||||
|
if (value === null || typeof value !== "object") return value;
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
for (const key of sortJsonKeys(Object.keys(value), parentKey)) {
|
||||||
|
result[key] = canonicalizeJson(value[key], key);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsonForDiff(value, indent = "") {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length === 0) return "[]";
|
||||||
|
const childIndent = `${indent} `;
|
||||||
|
return `[\n${value.map((entry) => `${childIndent}${formatJsonForDiff(entry, childIndent)},`).join("\n")}\n${indent}]`;
|
||||||
|
}
|
||||||
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||||
|
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
if (entries.length === 0) return "{}";
|
||||||
|
const childIndent = `${indent} `;
|
||||||
|
return `{\n${entries
|
||||||
|
.map(([key, entry]) => `${childIndent}${JSON.stringify(key)}: ${formatJsonForDiff(entry, childIndent)},`)
|
||||||
|
.join("\n")}\n${indent}}`;
|
||||||
|
}
|
||||||
|
|
||||||
function writeModelSnapshot(path, model) {
|
function writeModelSnapshot(path, model) {
|
||||||
writeFileSync(path, model === undefined ? "" : `${JSON.stringify(model, null, 2)}\n`);
|
writeFileSync(path, model === undefined ? "" : `${formatJsonForDiff(canonicalizeJson(model))}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeChangedLines(output) {
|
function writeChangedLines(output) {
|
||||||
@@ -94,6 +147,10 @@ function writeChangedLines(output) {
|
|||||||
try {
|
try {
|
||||||
run("git", ["worktree", "add", "--detach", baselineWorktree, "HEAD"], { cwd: repoRoot });
|
run("git", ["worktree", "add", "--detach", baselineWorktree, "HEAD"], { cwd: repoRoot });
|
||||||
worktreeAdded = true;
|
worktreeAdded = true;
|
||||||
|
copyFileSync(
|
||||||
|
join(repoRoot, "scripts", "generate-thinking-capabilities.mjs"),
|
||||||
|
join(baselineWorktree, "scripts", "generate-thinking-capabilities.mjs"),
|
||||||
|
);
|
||||||
|
|
||||||
const nodeModules = join(repoRoot, "node_modules");
|
const nodeModules = join(repoRoot, "node_modules");
|
||||||
if (existsSync(nodeModules)) {
|
if (existsSync(nodeModules)) {
|
||||||
@@ -107,17 +164,26 @@ try {
|
|||||||
generateCatalog(repoRoot, currentOutput, true);
|
generateCatalog(repoRoot, currentOutput, true);
|
||||||
formatProviderCatalogs(currentOutput);
|
formatProviderCatalogs(currentOutput);
|
||||||
|
|
||||||
|
if (thinkingOnly) {
|
||||||
|
console.log("Computing effective thinking capabilities...");
|
||||||
|
generateThinkingCatalog(baselineWorktree, join(baselineOutput, "models.json"), baselineThinkingOutput);
|
||||||
|
generateThinkingCatalog(repoRoot, join(currentOutput, "models.json"), currentThinkingOutput);
|
||||||
|
}
|
||||||
|
|
||||||
const beforeProviders = JSON.parse(readFileSync(join(baselineOutput, "providers.json"), "utf8"));
|
const beforeProviders = JSON.parse(readFileSync(join(baselineOutput, "providers.json"), "utf8"));
|
||||||
const afterProviders = JSON.parse(readFileSync(join(currentOutput, "providers.json"), "utf8"));
|
const afterProviders = JSON.parse(readFileSync(join(currentOutput, "providers.json"), "utf8"));
|
||||||
const providers = args.length > 0 ? args : [...new Set([...beforeProviders, ...afterProviders])].sort();
|
const providers =
|
||||||
|
requestedProviders.length > 0 ? requestedProviders : [...new Set([...beforeProviders, ...afterProviders])].sort();
|
||||||
|
const beforeCatalogOutput = thinkingOnly ? baselineThinkingOutput : baselineOutput;
|
||||||
|
const currentCatalogOutput = thinkingOnly ? currentThinkingOutput : currentOutput;
|
||||||
const beforeModelPath = "before-model.json";
|
const beforeModelPath = "before-model.json";
|
||||||
const afterModelPath = "after-model.json";
|
const afterModelPath = "after-model.json";
|
||||||
const changedModels = [];
|
const changedModels = [];
|
||||||
let differences = 0;
|
let differences = 0;
|
||||||
|
|
||||||
for (const provider of providers) {
|
for (const provider of providers) {
|
||||||
const beforeModels = readProviderCatalog(baselineOutput, provider);
|
const beforeModels = readProviderCatalog(beforeCatalogOutput, provider);
|
||||||
const afterModels = readProviderCatalog(currentOutput, provider);
|
const afterModels = readProviderCatalog(currentCatalogOutput, provider);
|
||||||
if (beforeModels === undefined && afterModels === undefined) {
|
if (beforeModels === undefined && afterModels === undefined) {
|
||||||
throw new Error(`Unknown provider: ${provider}`);
|
throw new Error(`Unknown provider: ${provider}`);
|
||||||
}
|
}
|
||||||
@@ -126,7 +192,7 @@ try {
|
|||||||
for (const modelId of modelIds) {
|
for (const modelId of modelIds) {
|
||||||
const beforeModel = beforeModels?.[modelId];
|
const beforeModel = beforeModels?.[modelId];
|
||||||
const afterModel = afterModels?.[modelId];
|
const afterModel = afterModels?.[modelId];
|
||||||
if (JSON.stringify(beforeModel) === JSON.stringify(afterModel)) continue;
|
if (JSON.stringify(canonicalizeJson(beforeModel)) === JSON.stringify(canonicalizeJson(afterModel))) continue;
|
||||||
|
|
||||||
writeModelSnapshot(join(temporaryRoot, beforeModelPath), beforeModel);
|
writeModelSnapshot(join(temporaryRoot, beforeModelPath), beforeModel);
|
||||||
writeModelSnapshot(join(temporaryRoot, afterModelPath), afterModel);
|
writeModelSnapshot(join(temporaryRoot, afterModelPath), afterModel);
|
||||||
@@ -156,7 +222,7 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (differences === 0) {
|
if (differences === 0) {
|
||||||
console.log(`No model catalog changes${args.length === 1 ? ` for ${args[0]}` : ""}.`);
|
console.log(`No model catalog changes${requestedProviders.length === 1 ? ` for ${requestedProviders[0]}` : ""}.`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`\n${differences} model catalog entr${differences === 1 ? "y" : "ies"} changed.`);
|
console.log(`\n${differences} model catalog entr${differences === 1 ? "y" : "ies"} changed.`);
|
||||||
for (const changedModel of changedModels) {
|
for (const changedModel of changedModels) {
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { getSupportedThinkingLevels } from "../packages/ai/src/models.ts";
|
||||||
|
|
||||||
|
const [catalogPath, outputDir] = process.argv.slice(2);
|
||||||
|
if (!catalogPath || !outputDir) {
|
||||||
|
throw new Error("Usage: node scripts/generate-thinking-capabilities.mjs <catalog-path> <output-dir>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
||||||
|
const providersDir = join(outputDir, "providers");
|
||||||
|
mkdirSync(providersDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const [provider, models] of Object.entries(catalog)) {
|
||||||
|
const capabilities = Object.fromEntries(
|
||||||
|
Object.entries(models).map(([id, model]) => {
|
||||||
|
const levels = getSupportedThinkingLevels(model);
|
||||||
|
const values = Object.fromEntries(
|
||||||
|
levels.flatMap((level) => {
|
||||||
|
const value = model.thinkingLevelMap?.[level];
|
||||||
|
return value !== undefined && value !== level ? [[level, value]] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return [id, Object.keys(values).length > 0 ? { levels, values } : { levels }];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
writeFileSync(join(providersDir, `${provider}.json`), JSON.stringify(capabilities));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user