feat(agent): merge main into agent-harness-tools
This commit is contained in:
@@ -287,3 +287,5 @@ QuintinShaw pr
|
||||
R-Taneja pr
|
||||
|
||||
zaycruz pr
|
||||
|
||||
mteam88 pr
|
||||
|
||||
@@ -49,8 +49,27 @@ jobs:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Build binaries
|
||||
run: ./scripts/build-binaries.sh
|
||||
- name: Create source archive
|
||||
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
|
||||
run: |
|
||||
@@ -83,7 +102,9 @@ jobs:
|
||||
cp "${binary_assets[@]}" "${GITHUB_WORKSPACE}/release-assets/"
|
||||
|
||||
cd "${GITHUB_WORKSPACE}/release-assets"
|
||||
source_asset="pi-${VERSION}-source.tar.gz"
|
||||
release_assets=(
|
||||
"${source_asset}"
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-x64.tar.gz
|
||||
pi-linux-x64.tar.gz
|
||||
@@ -125,7 +146,10 @@ jobs:
|
||||
|
||||
cd release-assets
|
||||
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
source_asset="pi-${VERSION}-source.tar.gz"
|
||||
expected_assets=(
|
||||
"${source_asset}"
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-x64.tar.gz
|
||||
pi-linux-x64.tar.gz
|
||||
@@ -144,7 +168,7 @@ jobs:
|
||||
|
||||
sha256sum -c SHA256SUMS
|
||||
|
||||
- name: Create draft GitHub Release and upload binaries
|
||||
- name: Create draft GitHub Release and upload assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -152,7 +176,10 @@ jobs:
|
||||
|
||||
cd release-assets
|
||||
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
source_asset="pi-${VERSION}-source.tar.gz"
|
||||
release_assets=(
|
||||
"${source_asset}"
|
||||
pi-darwin-arm64.tar.gz
|
||||
pi-darwin-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)
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
We treat npm dependency changes as reviewed code changes.
|
||||
|
||||
Generated
+18
-18
@@ -5041,10 +5041,10 @@
|
||||
},
|
||||
"packages/agent": {
|
||||
"name": "@earendil-works/pi-agent-core",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
@@ -5394,7 +5394,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
@@ -5700,12 +5700,12 @@
|
||||
},
|
||||
"packages/coding-agent": {
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-tui": "^0.81.0",
|
||||
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-tui": "^0.81.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -5746,32 +5746,32 @@
|
||||
},
|
||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.52.0"
|
||||
}
|
||||
},
|
||||
"packages/coding-agent/examples/extensions/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": {
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
}
|
||||
},
|
||||
"packages/coding-agent/examples/extensions/sandbox": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "0.0.26"
|
||||
}
|
||||
},
|
||||
"packages/coding-agent/examples/extensions/with-deps": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"dependencies": {
|
||||
"ms": "2.1.3"
|
||||
},
|
||||
@@ -6067,10 +6067,10 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@earendil-works/pi-server",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.81.0"
|
||||
"@earendil-works/pi-coding-agent": "^0.81.1"
|
||||
},
|
||||
"bin": {
|
||||
"server": "dist/cli.js"
|
||||
@@ -6084,11 +6084,11 @@
|
||||
},
|
||||
"packages/storage/sqlite-node": {
|
||||
"name": "@earendil-works/pi-storage-sqlite-node",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.0"
|
||||
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.81.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -6096,7 +6096,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@earendil-works/pi-tui",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
|
||||
- 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
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -29,7 +29,7 @@ const agent = new Agent({
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model,
|
||||
},
|
||||
streamFunction: models.streamSimple.bind(models),
|
||||
streamFn: models.streamSimple.bind(models),
|
||||
});
|
||||
|
||||
agent.subscribe((event) => {
|
||||
@@ -199,7 +199,7 @@ const agent = new Agent({
|
||||
followUpMode: "one-at-a-time",
|
||||
|
||||
// Required stream function
|
||||
streamFunction: models.streamSimple.bind(models),
|
||||
streamFn: models.streamSimple.bind(models),
|
||||
|
||||
// Session ID for provider caching
|
||||
sessionId: "session-123",
|
||||
@@ -386,7 +386,7 @@ Handle custom types in `convertToLlm`:
|
||||
|
||||
```typescript
|
||||
const agent = new Agent({
|
||||
streamFunction: models.streamSimple.bind(models),
|
||||
streamFn: models.streamSimple.bind(models),
|
||||
convertToLlm: (messages) => messages.flatMap(m => {
|
||||
if (m.role === "notification") return []; // Filter out
|
||||
return [m];
|
||||
@@ -457,7 +457,7 @@ For browser apps that proxy through a backend:
|
||||
import { Agent, streamProxy } from "@earendil-works/pi-agent-core";
|
||||
|
||||
const agent = new Agent({
|
||||
streamFunction: (model, context, options) =>
|
||||
streamFn: (model, context, options) =>
|
||||
streamProxy(model, context, {
|
||||
...options,
|
||||
authToken: "...",
|
||||
@@ -489,13 +489,13 @@ const config: AgentLoopConfig = {
|
||||
|
||||
const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };
|
||||
|
||||
const streamFunction = models.streamSimple.bind(models);
|
||||
for await (const event of agentLoop([userMessage], context, config, undefined, streamFunction)) {
|
||||
const streamFn = models.streamSimple.bind(models);
|
||||
for await (const event of agentLoop([userMessage], context, config, undefined, streamFn)) {
|
||||
console.log(event.type);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -29,7 +29,7 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ToolResultMessage,
|
||||
validateToolArguments,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { getDefaultStreamFn } from "./stream-fn.ts";
|
||||
import type {
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
@@ -32,7 +33,7 @@ export function agentLoop(
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
streamFunction: StreamFn,
|
||||
streamFn: StreamFn,
|
||||
): EventStream<AgentEvent, AgentMessage[]> {
|
||||
const stream = createAgentStream();
|
||||
|
||||
@@ -44,7 +45,7 @@ export function agentLoop(
|
||||
stream.push(event);
|
||||
},
|
||||
signal,
|
||||
streamFunction,
|
||||
streamFn,
|
||||
).then((messages) => {
|
||||
stream.end(messages);
|
||||
});
|
||||
@@ -64,7 +65,7 @@ export function agentLoopContinue(
|
||||
context: AgentContext,
|
||||
config: AgentLoopConfig,
|
||||
signal: AbortSignal | undefined,
|
||||
streamFunction: StreamFn,
|
||||
streamFn: StreamFn,
|
||||
): EventStream<AgentEvent, AgentMessage[]> {
|
||||
if (context.messages.length === 0) {
|
||||
throw new Error("Cannot continue: no messages in context");
|
||||
@@ -83,7 +84,7 @@ export function agentLoopContinue(
|
||||
stream.push(event);
|
||||
},
|
||||
signal,
|
||||
streamFunction,
|
||||
streamFn,
|
||||
).then((messages) => {
|
||||
stream.end(messages);
|
||||
});
|
||||
@@ -97,7 +98,7 @@ export async function runAgentLoop(
|
||||
config: AgentLoopConfig,
|
||||
emit: AgentEventSink,
|
||||
signal: AbortSignal | undefined,
|
||||
streamFunction: StreamFn,
|
||||
streamFn: StreamFn,
|
||||
): Promise<AgentMessage[]> {
|
||||
const newMessages: AgentMessage[] = [...prompts];
|
||||
const currentContext: AgentContext = {
|
||||
@@ -112,7 +113,7 @@ export async function runAgentLoop(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -121,7 +122,7 @@ export async function runAgentLoopContinue(
|
||||
config: AgentLoopConfig,
|
||||
emit: AgentEventSink,
|
||||
signal: AbortSignal | undefined,
|
||||
streamFunction: StreamFn,
|
||||
streamFn: StreamFn,
|
||||
): Promise<AgentMessage[]> {
|
||||
if (context.messages.length === 0) {
|
||||
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: "turn_start" });
|
||||
|
||||
await runLoop(currentContext, newMessages, config, signal, emit, streamFunction);
|
||||
await runLoop(currentContext, newMessages, config, signal, emit, streamFn ?? getDefaultStreamFn());
|
||||
return newMessages;
|
||||
}
|
||||
|
||||
|
||||
+22
-19
@@ -8,6 +8,7 @@ import type {
|
||||
Transport,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
|
||||
import { getDefaultStreamFn } from "./stream-fn.ts";
|
||||
import type {
|
||||
AfterToolCallContext,
|
||||
AfterToolCallResult,
|
||||
@@ -97,7 +98,7 @@ export interface AgentOptions {
|
||||
initialState?: Partial<Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">>;
|
||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
streamFunction: StreamFn;
|
||||
streamFn: StreamFn;
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
onPayload?: SimpleStreamOptions["onPayload"];
|
||||
onResponse?: SimpleStreamOptions["onResponse"];
|
||||
@@ -207,24 +208,26 @@ export class Agent {
|
||||
public toolExecution: ToolExecutionMode;
|
||||
|
||||
constructor(options: AgentOptions) {
|
||||
this._state = createMutableAgentState(options.initialState);
|
||||
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
|
||||
this.transformContext = options.transformContext;
|
||||
this.streamFunction = options.streamFunction;
|
||||
this.getApiKey = options.getApiKey;
|
||||
this.onPayload = options.onPayload;
|
||||
this.onResponse = options.onResponse;
|
||||
this.beforeToolCall = options.beforeToolCall;
|
||||
this.afterToolCall = options.afterToolCall;
|
||||
this.prepareNextTurn = options.prepareNextTurn;
|
||||
this.prepareNextTurnWithContext = options.prepareNextTurnWithContext;
|
||||
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
|
||||
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
|
||||
this.sessionId = options.sessionId;
|
||||
this.thinkingBudgets = options.thinkingBudgets;
|
||||
this.transport = options.transport ?? "auto";
|
||||
this.maxRetryDelayMs = options.maxRetryDelayMs;
|
||||
this.toolExecution = options.toolExecution ?? "parallel";
|
||||
// Older compiled consumers may omit options or streamFn even though the current API requires them.
|
||||
const runtimeOptions: Partial<AgentOptions> = options ?? {};
|
||||
this._state = createMutableAgentState(runtimeOptions.initialState);
|
||||
this.convertToLlm = runtimeOptions.convertToLlm ?? defaultConvertToLlm;
|
||||
this.transformContext = runtimeOptions.transformContext;
|
||||
this.streamFunction = runtimeOptions.streamFn ?? getDefaultStreamFn();
|
||||
this.getApiKey = runtimeOptions.getApiKey;
|
||||
this.onPayload = runtimeOptions.onPayload;
|
||||
this.onResponse = runtimeOptions.onResponse;
|
||||
this.beforeToolCall = runtimeOptions.beforeToolCall;
|
||||
this.afterToolCall = runtimeOptions.afterToolCall;
|
||||
this.prepareNextTurn = runtimeOptions.prepareNextTurn;
|
||||
this.prepareNextTurnWithContext = runtimeOptions.prepareNextTurnWithContext;
|
||||
this.steeringQueue = new PendingMessageQueue(runtimeOptions.steeringMode ?? "one-at-a-time");
|
||||
this.followUpQueue = new PendingMessageQueue(runtimeOptions.followUpMode ?? "one-at-a-time");
|
||||
this.sessionId = runtimeOptions.sessionId;
|
||||
this.thinkingBudgets = runtimeOptions.thinkingBudgets;
|
||||
this.transport = runtimeOptions.transport ?? "auto";
|
||||
this.maxRetryDelayMs = runtimeOptions.maxRetryDelayMs;
|
||||
this.toolExecution = runtimeOptions.toolExecution ?? "parallel";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type ImageContent,
|
||||
type Model,
|
||||
type Models,
|
||||
type RetryCallbacks,
|
||||
type RetryPolicy,
|
||||
type UserMessage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import { runAgentLoop } from "../agent-loop.ts";
|
||||
@@ -182,6 +184,7 @@ export class AgentHarness<
|
||||
private systemPrompt: AgentHarnessOptions<TContext, TSkill, TPromptTemplate, TTool>["systemPrompt"];
|
||||
private toolContext: AgentHarnessToolContextSource<TContext> | undefined;
|
||||
private streamOptions: AgentHarnessStreamOptions;
|
||||
private retry: RetryPolicy | undefined;
|
||||
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
private tools = new Map<string, TTool>();
|
||||
private activeToolNames: string[];
|
||||
@@ -197,6 +200,7 @@ export class AgentHarness<
|
||||
this.models = options.models;
|
||||
this.resources = options.resources ?? {};
|
||||
this.streamOptions = cloneStreamOptions(options.streamOptions);
|
||||
this.retry = options.retry;
|
||||
this.systemPrompt = options.systemPrompt;
|
||||
this.toolContext = options.toolContext;
|
||||
this.validateUniqueNames(
|
||||
@@ -260,6 +264,15 @@ export class AgentHarness<
|
||||
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(
|
||||
model: Model<any>,
|
||||
sessionId: string,
|
||||
@@ -741,7 +754,16 @@ export class AgentHarness<
|
||||
const provided = hookResult?.compaction;
|
||||
const compactResult = 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;
|
||||
const result = compactResult.value;
|
||||
const entryId = await this.session.appendCompaction(
|
||||
@@ -803,6 +825,8 @@ export class AgentHarness<
|
||||
signal: new AbortController().signal,
|
||||
customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
|
||||
replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
|
||||
retry: this.retry,
|
||||
callbacks: this.retryCallbacks("branch_summary"),
|
||||
});
|
||||
if (!branchSummary.ok) {
|
||||
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 {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "../messages.ts";
|
||||
import type { BranchSummaryResult, Session, SessionTreeEntry } 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 {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
@@ -61,6 +61,10 @@ export interface GenerateBranchSummaryOptions {
|
||||
replaceInstructions?: boolean;
|
||||
/** Tokens reserved for prompt and model output. Defaults to 16384. */
|
||||
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. */
|
||||
@@ -200,7 +204,16 @@ export async function generateBranchSummary(
|
||||
entries: SessionTreeEntry[],
|
||||
options: GenerateBranchSummaryOptions,
|
||||
): 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 tokenBudget = contextWindow - reserveTokens;
|
||||
|
||||
@@ -228,10 +241,13 @@ export async function generateBranchSummary(
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const response = await models.completeSimple(
|
||||
const response = await completeSimpleWithRetries(
|
||||
models,
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ signal, maxTokens: 2048 },
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"));
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
contentText,
|
||||
type ImageContent,
|
||||
type Model,
|
||||
type Models,
|
||||
type RetryCallbacks,
|
||||
type RetryPolicy,
|
||||
retryAssistantCall,
|
||||
type SimpleStreamOptions,
|
||||
type TextContent,
|
||||
type Usage,
|
||||
} from "@earendil-works/pi-ai";
|
||||
@@ -109,6 +114,17 @@ export interface CompactionResult<T = unknown> {
|
||||
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 {
|
||||
return {
|
||||
input: first.input + second.input,
|
||||
@@ -501,6 +517,8 @@ export async function generateSummary(
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
const result = await generateSummaryWithUsage(
|
||||
currentMessages,
|
||||
@@ -511,6 +529,8 @@ export async function generateSummary(
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
return result.ok ? ok(result.value.text) : err(result.error);
|
||||
}
|
||||
@@ -525,6 +545,8 @@ export async function generateSummaryWithUsage(
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.8 * reserveTokens),
|
||||
@@ -555,10 +577,13 @@ export async function generateSummaryWithUsage(
|
||||
? { maxTokens, signal, reasoning: thinkingLevel }
|
||||
: { maxTokens, signal };
|
||||
|
||||
const response = await models.completeSimple(
|
||||
const response = await completeSimpleWithRetries(
|
||||
models,
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
completionOptions,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(new CompactionError("aborted", response.errorMessage || "Summarization aborted"));
|
||||
@@ -700,6 +725,8 @@ export async function compact(
|
||||
customInstructions?: string,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<Result<CompactionResult, CompactionError>> {
|
||||
const {
|
||||
firstKeptEntryId,
|
||||
@@ -733,6 +760,8 @@ export async function compact(
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
if (!historyResult.ok) return err(historyResult.error);
|
||||
historyText = historyResult.value.text;
|
||||
@@ -745,6 +774,8 @@ export async function compact(
|
||||
settings.reserveTokens,
|
||||
signal,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
if (!turnPrefixResult.ok) return err(turnPrefixResult.error);
|
||||
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`;
|
||||
@@ -761,6 +792,8 @@ export async function compact(
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
if (!summaryResult.ok) return err(summaryResult.error);
|
||||
summary = summaryResult.value.text;
|
||||
@@ -786,6 +819,8 @@ async function generateTurnPrefixSummary(
|
||||
reserveTokens: number,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<Result<{ text: string; usage: Usage }, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.5 * reserveTokens),
|
||||
@@ -802,12 +837,17 @@ async function generateTurnPrefixSummary(
|
||||
},
|
||||
];
|
||||
|
||||
const response = await models.completeSimple(
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
const completionOptions =
|
||||
model.reasoning && thinkingLevel && thinkingLevel !== "off"
|
||||
? { 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") {
|
||||
return err(new CompactionError("aborted", response.errorMessage || "Turn prefix summarization aborted"));
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
ImageContent,
|
||||
Model,
|
||||
Models,
|
||||
RetryPolicy,
|
||||
SimpleStreamOptions,
|
||||
TextContent,
|
||||
Transport,
|
||||
@@ -659,6 +660,25 @@ export interface SessionTreeEvent {
|
||||
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 {
|
||||
type: "model_update";
|
||||
model: Model<any>;
|
||||
@@ -709,6 +729,9 @@ export type AgentHarnessOwnEvent<
|
||||
| SessionCompactEvent
|
||||
| SessionBeforeTreeEvent
|
||||
| SessionTreeEvent
|
||||
| RetryScheduledEvent
|
||||
| RetryAttemptStartEvent
|
||||
| RetryFinishedEvent
|
||||
| ModelUpdateEvent
|
||||
| ThinkingLevelUpdateEvent
|
||||
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
|
||||
@@ -778,6 +801,9 @@ export type AgentHarnessEventResultMap = {
|
||||
session_compact: undefined;
|
||||
session_before_tree: SessionBeforeTreeResult | undefined;
|
||||
session_tree: undefined;
|
||||
retry_scheduled: undefined;
|
||||
retry_attempt_start: undefined;
|
||||
retry_finished: undefined;
|
||||
model_update: undefined;
|
||||
thinking_level_update: undefined;
|
||||
resources_update: undefined;
|
||||
@@ -897,6 +923,8 @@ export interface AgentHarnessOptions<
|
||||
}) => string | Promise<string>);
|
||||
/** Curated stream/provider request options. Snapshotted at turn start. */
|
||||
streamOptions?: AgentHarnessStreamOptions;
|
||||
/** Optional retry policy for generated compaction and branch-summary requests. */
|
||||
retry?: RetryPolicy;
|
||||
model: Model<any>;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
activeToolNames?: string[];
|
||||
|
||||
@@ -44,5 +44,7 @@ export * from "./harness/utils/shell-output.ts";
|
||||
export * from "./harness/utils/truncate.ts";
|
||||
// Proxy utilities
|
||||
export * from "./proxy.ts";
|
||||
// Stream defaults
|
||||
export { setDefaultStreamFn } from "./stream-fn.ts";
|
||||
// Types
|
||||
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.
|
||||
* 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
|
||||
* ```typescript
|
||||
* const agent = new Agent({
|
||||
* streamFunction: (model, context, options) =>
|
||||
* streamFn: (model, context, options) =>
|
||||
* streamProxy(model, context, {
|
||||
* ...options,
|
||||
* 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 { describe, expect, it } from "vitest";
|
||||
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";
|
||||
|
||||
// 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[];
|
||||
}
|
||||
|
||||
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", () => {
|
||||
it("should emit events with AgentMessage types", async () => {
|
||||
const context: AgentContext = {
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai/compat";
|
||||
import { Type } from "typebox";
|
||||
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
|
||||
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
||||
@@ -75,8 +82,29 @@ function createDeferred(): {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
||||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
expect(agent.state).toBeDefined();
|
||||
expect(agent.state.systemPrompt).toBe("");
|
||||
@@ -93,7 +121,7 @@ describe("Agent", () => {
|
||||
it("should create an agent instance with custom initial state", () => {
|
||||
const customModel = getModel("openai", "gpt-4o-mini");
|
||||
const agent = new Agent({
|
||||
streamFunction: unusedStreamFunction,
|
||||
streamFn: unusedStreamFunction,
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model: customModel,
|
||||
@@ -107,7 +135,7 @@ describe("Agent", () => {
|
||||
});
|
||||
|
||||
it("should subscribe to events", () => {
|
||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
||||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
let eventCount = 0;
|
||||
const unsubscribe = agent.subscribe((_event) => {
|
||||
@@ -130,7 +158,7 @@ describe("Agent", () => {
|
||||
|
||||
it("emits full lifecycle events for thrown run failures", async () => {
|
||||
const agent = new Agent({
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
throw new Error("provider exploded");
|
||||
},
|
||||
});
|
||||
@@ -162,7 +190,7 @@ describe("Agent", () => {
|
||||
it("should await async subscribers before prompt resolves", async () => {
|
||||
const barrier = createDeferred();
|
||||
const agent = new Agent({
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") });
|
||||
@@ -200,7 +228,7 @@ describe("Agent", () => {
|
||||
it("waitForIdle should wait for async subscribers", async () => {
|
||||
const barrier = createDeferred();
|
||||
const agent = new Agent({
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
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 () => {
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const agent = new Agent({
|
||||
streamFunction: (_model, _context, options) => {
|
||||
streamFn: (_model, _context, options) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
@@ -298,7 +326,7 @@ describe("Agent", () => {
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: { tools: [tool] },
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
@@ -373,7 +401,7 @@ describe("Agent", () => {
|
||||
};
|
||||
const agent = new Agent({
|
||||
initialState: { tools: [settledTool, slowTool] },
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({
|
||||
@@ -412,7 +440,7 @@ describe("Agent", () => {
|
||||
});
|
||||
|
||||
it("should update state with mutators", () => {
|
||||
const agent = new Agent({ streamFunction: unusedStreamFunction });
|
||||
const agent = new Agent({ streamFn: unusedStreamFunction });
|
||||
|
||||
// Test setSystemPrompt
|
||||
agent.state.systemPrompt = "Custom prompt";
|
||||
@@ -451,7 +479,7 @@ describe("Agent", () => {
|
||||
});
|
||||
|
||||
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() };
|
||||
agent.steer(message);
|
||||
@@ -461,7 +489,7 @@ describe("Agent", () => {
|
||||
});
|
||||
|
||||
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() };
|
||||
agent.followUp(message);
|
||||
@@ -471,7 +499,7 @@ describe("Agent", () => {
|
||||
});
|
||||
|
||||
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
|
||||
expect(() => agent.abort()).not.toThrow();
|
||||
@@ -481,7 +509,7 @@ describe("Agent", () => {
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
const agent = new Agent({
|
||||
// Use a stream function that responds to abort
|
||||
streamFunction: (_model, _context, options) => {
|
||||
streamFn: (_model, _context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
@@ -520,7 +548,7 @@ describe("Agent", () => {
|
||||
it("should throw when continue() called while streaming", async () => {
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
const agent = new Agent({
|
||||
streamFunction: (_model, _context, options) => {
|
||||
streamFn: (_model, _context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
@@ -555,7 +583,7 @@ describe("Agent", () => {
|
||||
|
||||
it("continue() should process queued follow-up messages after an assistant turn", async () => {
|
||||
const agent = new Agent({
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
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 () => {
|
||||
let responseCount = 0;
|
||||
const agent = new Agent({
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
responseCount++;
|
||||
queueMicrotask(() => {
|
||||
@@ -652,7 +680,7 @@ describe("Agent", () => {
|
||||
sawAbortSignal = signal instanceof AbortSignal;
|
||||
return undefined;
|
||||
},
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
requestCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
@@ -680,7 +708,7 @@ describe("Agent", () => {
|
||||
let receivedSessionId: string | undefined;
|
||||
const agent = new Agent({
|
||||
sessionId: "session-abc",
|
||||
streamFunction: (_model, _context, options) => {
|
||||
streamFn: (_model, _context, options) => {
|
||||
receivedSessionId = options?.sessionId;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
|
||||
@@ -38,7 +38,7 @@ afterEach(() => {
|
||||
|
||||
async function basicPrompt(model: Model<string>) {
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant. Keep your responses concise.",
|
||||
model,
|
||||
@@ -61,7 +61,7 @@ async function basicPrompt(model: Model<string>) {
|
||||
|
||||
async function toolExecution(model: Model<string>) {
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.",
|
||||
model,
|
||||
@@ -101,7 +101,7 @@ async function toolExecution(model: Model<string>) {
|
||||
|
||||
async function abortExecution(model: Model<string>) {
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model,
|
||||
@@ -129,7 +129,7 @@ async function abortExecution(model: Model<string>) {
|
||||
|
||||
async function stateUpdates(model: Model<string>) {
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model,
|
||||
@@ -162,7 +162,7 @@ async function stateUpdates(model: Model<string>) {
|
||||
|
||||
async function multiTurnConversation(model: Model<string>) {
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model,
|
||||
@@ -244,7 +244,7 @@ describe("Agent integration with faux provider", () => {
|
||||
faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]);
|
||||
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant.",
|
||||
model: faux.getModel(),
|
||||
@@ -269,7 +269,7 @@ describe("Agent.continue() with faux provider", () => {
|
||||
it("throws when no messages in context", async () => {
|
||||
const faux = createFauxRegistration();
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "Test",
|
||||
model: faux.getModel(),
|
||||
@@ -283,7 +283,7 @@ describe("Agent.continue() with faux provider", () => {
|
||||
const faux = createFauxRegistration();
|
||||
const model = faux.getModel();
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "Test",
|
||||
model,
|
||||
@@ -318,7 +318,7 @@ describe("Agent.continue() with faux provider", () => {
|
||||
const faux = createFauxRegistration();
|
||||
faux.setResponses([fauxAssistantMessage("HELLO WORLD")]);
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt: "You are a helpful assistant. Follow instructions exactly.",
|
||||
model: faux.getModel(),
|
||||
@@ -353,7 +353,7 @@ describe("Agent.continue() with faux provider", () => {
|
||||
const model = faux.getModel();
|
||||
faux.setResponses([fauxAssistantMessage("The answer is 8.")]);
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
systemPrompt:
|
||||
"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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const registration = newFaux();
|
||||
registration.setResponses([fauxAssistantMessage("## Goal\nBranch summary")]);
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
## [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
|
||||
|
||||
### 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 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 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
|
||||
import {
|
||||
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
|
||||
CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
|
||||
@@ -83,6 +84,7 @@ interface ModelsDevModel {
|
||||
name: string;
|
||||
tool_call?: boolean;
|
||||
reasoning?: boolean;
|
||||
reasoning_options?: ModelsDevReasoningOption[];
|
||||
limit?: {
|
||||
context?: number;
|
||||
output?: number;
|
||||
@@ -112,6 +114,12 @@ interface ModelsDevModel {
|
||||
};
|
||||
}
|
||||
|
||||
interface ModelsDevProvider {
|
||||
models?: Record<string, ModelsDevModel>;
|
||||
}
|
||||
|
||||
type ModelsDevCatalog = Record<string, ModelsDevProvider>;
|
||||
|
||||
interface NvidiaNimModelListItem {
|
||||
id: string;
|
||||
}
|
||||
@@ -257,15 +265,6 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = {
|
||||
max: "max",
|
||||
} 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_COST = {
|
||||
input: 3,
|
||||
@@ -398,6 +397,43 @@ function mergeThinkingLevelMap(model: Model<any>, map: NonNullable<Model<any>["t
|
||||
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 {
|
||||
if (!reasoning) return TOGETHER_BASE_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 isOpenRouterDeveloperRoleModel =
|
||||
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 {
|
||||
supportsStore: !isNonStandard,
|
||||
@@ -957,7 +994,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
console.log("Fetching models from models.dev API...");
|
||||
const response = await fetch("https://models.dev/api.json");
|
||||
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 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,
|
||||
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,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions("anthropic", modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1056,6 +1095,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: source.limit?.context || 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,
|
||||
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,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions("openai", modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1151,6 +1193,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: m.limit?.context || 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,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions("cerebras", modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1204,6 +1248,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
compat: { sendSessionAffinityHeaders: true },
|
||||
});
|
||||
recordModelsDevReasoningOptions("cloudflare-workers-ai", modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1260,6 +1305,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
...(compat ? { compat } : {}),
|
||||
});
|
||||
recordModelsDevReasoningOptions("cloudflare-ai-gateway", id, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1288,6 +1334,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: m.limit?.context || 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,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1357,6 +1405,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: m.limit?.context || 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,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions("huggingface", modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1423,6 +1473,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
supportsLongCacheRetention: false,
|
||||
},
|
||||
});
|
||||
recordModelsDevReasoningOptions("fireworks", modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1457,6 +1508,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: m.limit?.context || 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,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions("together", modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1596,6 +1649,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions(variant.provider, modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1646,6 +1700,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
};
|
||||
|
||||
models.push(copilotModel);
|
||||
recordModelsDevReasoningOptions("github-copilot", modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1679,6 +1734,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1716,7 +1772,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
forceAdaptiveThinking: true,
|
||||
},
|
||||
reasoning: isKimiK3 || m.reasoning === true,
|
||||
...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}),
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || impliedCost?.input || 0,
|
||||
@@ -1727,6 +1782,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions("kimi-coding", normalizedId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1761,6 +1817,8 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
if (isKimiK3) {
|
||||
compat.requiresReasoningContentOnAssistantMessages = true;
|
||||
compat.deferredToolsMode = "kimi";
|
||||
compat.thinkingFormat = "openai";
|
||||
compat.supportsReasoningEffort = true;
|
||||
}
|
||||
models.push({
|
||||
id: modelId,
|
||||
@@ -1769,7 +1827,6 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
provider,
|
||||
baseUrl,
|
||||
reasoning: isKimiK3 || m.reasoning === true,
|
||||
...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}),
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
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,
|
||||
compat,
|
||||
});
|
||||
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1837,6 +1895,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
||||
contextWindow: m.limit?.context || 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,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
recordModelsDevReasoningOptions(provider, modelId, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2392,8 +2452,9 @@ async function generateModels() {
|
||||
allModels.push(...azureOpenAiModels);
|
||||
|
||||
for (const model of allModels) {
|
||||
applyThinkingLevelMetadata(model);
|
||||
applyOpenAICompletionsCompatMetadata(model);
|
||||
applyModelsDevReasoningOptionMetadata(model);
|
||||
applyThinkingLevelMetadata(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 {
|
||||
for (let i = messages.length - 1; i >= 0; 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)) {
|
||||
return;
|
||||
}
|
||||
@@ -840,7 +840,7 @@ function addCacheControlToMessage(
|
||||
message: ChatCompletionMessageParam,
|
||||
cacheControl: OpenAICompatCacheControl,
|
||||
): boolean {
|
||||
if (message.role === "user" || message.role === "assistant") {
|
||||
if (message.role === "user" || message.role === "assistant" || message.role === "tool") {
|
||||
return addCacheControlToTextContent(message, cacheControl);
|
||||
}
|
||||
return false;
|
||||
@@ -850,6 +850,7 @@ function addCacheControlToTextContent(
|
||||
message:
|
||||
| ChatCompletionInstructionMessageParam
|
||||
| ChatCompletionAssistantMessageParam
|
||||
| ChatCompletionToolMessageParam
|
||||
| Extract<ChatCompletionMessageParam, { role: "user" }>,
|
||||
cacheControl: OpenAICompatCacheControl,
|
||||
): 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>;
|
||||
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
xai: () => 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;
|
||||
};
|
||||
|
||||
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> => {
|
||||
if (bundledLoaders) return bundledLoaders.xai();
|
||||
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { anthropicOAuth } from "./auth/oauth/anthropic.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 { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
|
||||
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
|
||||
@@ -11,6 +12,7 @@ export function registerBunOAuthFlows(): void {
|
||||
anthropic: () => anthropicOAuth,
|
||||
openaiCodex: () => openaiCodexOAuth,
|
||||
githubCopilot: () => githubCopilotOAuth,
|
||||
kimiCoding: () => kimiCodingOAuth,
|
||||
xai: () => xaiOAuth,
|
||||
radius: createRadiusOAuth,
|
||||
});
|
||||
|
||||
@@ -53,6 +53,14 @@ export const GOOGLE_MODELS = values as {
|
||||
id: "gemini-3.5-flash";
|
||||
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"> & {
|
||||
id: "gemini-flash-latest";
|
||||
provider: "google";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { KIMI_CODING_MODELS } from "./kimi-coding.models.ts";
|
||||
|
||||
@@ -8,7 +9,14 @@ export function kimiCodingProvider(): Provider<"anthropic-messages"> {
|
||||
id: "kimi-coding",
|
||||
name: "Kimi For 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),
|
||||
api: anthropicMessagesApi(),
|
||||
});
|
||||
|
||||
@@ -77,6 +77,14 @@ export const OPENCODE_MODELS = values as {
|
||||
id: "gemini-3.5-flash";
|
||||
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"> & {
|
||||
id: "glm-5";
|
||||
provider: "opencode";
|
||||
@@ -185,6 +193,10 @@ export const OPENCODE_MODELS = values as {
|
||||
id: "kimi-k2.7-code";
|
||||
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"> & {
|
||||
id: "mimo-v2.5-free";
|
||||
provider: "opencode";
|
||||
|
||||
@@ -229,6 +229,14 @@ export const OPENROUTER_MODELS = values as {
|
||||
id: "google/gemini-3.5-flash";
|
||||
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"> & {
|
||||
id: "google/gemma-3-12b-it";
|
||||
provider: "openrouter";
|
||||
@@ -737,6 +745,14 @@ export const OPENROUTER_MODELS = values as {
|
||||
id: "poolside/laguna-m.1:free";
|
||||
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"> & {
|
||||
id: "poolside/laguna-xs-2.1";
|
||||
provider: "openrouter";
|
||||
|
||||
@@ -265,6 +265,14 @@ export const VERCEL_AI_GATEWAY_MODELS = values as {
|
||||
id: "google/gemini-3.5-flash";
|
||||
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"> & {
|
||||
id: "google/gemma-4-26b-a4b-it";
|
||||
provider: "vercel-ai-gateway";
|
||||
@@ -629,6 +637,14 @@ export const VERCEL_AI_GATEWAY_MODELS = values as {
|
||||
id: "openai/o4-mini";
|
||||
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"> & {
|
||||
id: "sakana/fugu-ultra";
|
||||
provider: "vercel-ai-gateway";
|
||||
|
||||
@@ -524,7 +524,7 @@ export interface OpenAICompletionsCompat {
|
||||
zaiToolStream?: boolean;
|
||||
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
|
||||
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";
|
||||
/** Whether to send session-affinity data from `options.sessionId`. Default: false. */
|
||||
sendSessionAffinityHeaders?: boolean;
|
||||
|
||||
@@ -51,6 +51,9 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
"connection.?lost",
|
||||
"other side closed",
|
||||
"fetch failed",
|
||||
"getaddrinfo",
|
||||
"ENOTFOUND",
|
||||
"EAI_AGAIN",
|
||||
"upstream.?connect",
|
||||
"reset before headers",
|
||||
"socket hang up",
|
||||
@@ -85,6 +88,128 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
"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
|
||||
* 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 { stream as streamOpenAICompletions } from "../src/api/openai-completions.ts";
|
||||
import { getModel } from "../src/compat.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import type { Message, Model } from "../src/types.ts";
|
||||
|
||||
interface CacheControl {
|
||||
type: "ephemeral";
|
||||
@@ -74,6 +74,7 @@ vi.mock("openai", () => {
|
||||
async function capturePayload(
|
||||
model: Model<"openai-completions">,
|
||||
options?: { cacheRetention?: "none" | "short" | "long" },
|
||||
messages?: Message[],
|
||||
): Promise<CapturedParams> {
|
||||
const timestamp = Date.now();
|
||||
|
||||
@@ -81,7 +82,7 @@ async function capturePayload(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "System prompt",
|
||||
messages: [{ role: "user", content: "Hello", timestamp }],
|
||||
messages: messages ?? [{ role: "user", content: "Hello", timestamp }],
|
||||
tools: [
|
||||
{
|
||||
name: "read",
|
||||
@@ -158,6 +159,47 @@ describe("openai-completions cacheControlFormat", () => {
|
||||
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 () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
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 { isRetryableAssistantError } from "../src/utils/retry.ts";
|
||||
import { isRetryableAssistantError, type RetryPolicy, retryAssistantCall } from "../src/utils/retry.ts";
|
||||
|
||||
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.";
|
||||
@@ -10,6 +10,8 @@ const nvidiaNIMResourceExhaustedMessage = "ResourceExhausted: Worker local total
|
||||
const bunFetchSocketClosedMessage =
|
||||
"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 wrappedDnsLookupError =
|
||||
"The pending stream has been canceled (caused by: getaddrinfo ENOTFOUND bedrock-runtime.us-east-1.amazonaws.com)";
|
||||
|
||||
describe("provider retry classification", () => {
|
||||
it("matches explicit provider retry guidance", () => {
|
||||
@@ -38,6 +40,15 @@ describe("provider retry classification", () => {
|
||||
).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", () => {
|
||||
expect(
|
||||
isRetryableAssistantError(
|
||||
@@ -66,3 +77,136 @@ describe("provider retry classification", () => {
|
||||
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) => {
|
||||
const model = getModel("openai", modelId);
|
||||
expect(model).toBeDefined();
|
||||
expect(getSupportedThinkingLevels(model!)).toEqual([
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
]);
|
||||
expect(getSupportedThinkingLevels(model!)).toEqual(["off", "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", () => {
|
||||
const model = getModel("kimi-coding", "k3");
|
||||
expect(model).toBeDefined();
|
||||
|
||||
@@ -44,7 +44,15 @@ describe("Together models", () => {
|
||||
|
||||
it("models Together reasoning controls from the Together API surface", () => {
|
||||
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({
|
||||
supportsReasoningEffort: true,
|
||||
thinkingFormat: "openai",
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
## [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
|
||||
|
||||
### 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 `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.
|
||||
|
||||
@@ -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" } }`.
|
||||
`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_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_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.
|
||||
|
||||
@@ -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 |
|
||||
| `thinkingFormat` | Use `reasoning_effort`, `openrouter`, `deepseek`, `together`, `zai`, `qwen`, `chat-template`, or `qwen-chat-template` thinking parameters |
|
||||
| `chatTemplateKwargs` | `chat_template_kwargs` values for `thinkingFormat: "chat-template"`; use `{ "$var": "thinking.enabled" }` or `{ "$var": "thinking.effort" }` for pi-controlled thinking values |
|
||||
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user/assistant text content. Currently only `anthropic` is supported. |
|
||||
| `cacheControlFormat` | Use Anthropic-style `cache_control` markers on the system prompt, last tool definition, and last user, assistant, or tool-result text content. Currently only `anthropic` is supported. |
|
||||
| `sendSessionAffinityHeaders` | For `openai-completions`, send session-affinity headers from the session id when caching is enabled. Default: `false`. |
|
||||
| `sessionAffinityFormat` | For `openai-completions` and `openai-responses`, the session-affinity header format: `openai` sends `session_id`/`x-client-request-id` (completions also `x-session-affinity`), `openai-nosession` omits the underscore-containing `session_id` header, `openrouter` sends `x-session-id`. Does not affect the `prompt_cache_key` body param. Default: auto-detected. |
|
||||
| `supportsStrictMode` | Include the `strict` field in tool definitions |
|
||||
|
||||
@@ -851,6 +851,9 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
|
||||
| `compaction_end` | Compaction completes |
|
||||
| `auto_retry_start` | Auto-retry begins (after transient error) |
|
||||
| `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 |
|
||||
|
||||
### 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
|
||||
|
||||
Emitted when an extension throws an error.
|
||||
|
||||
@@ -319,6 +319,9 @@ session.subscribe((event) => {
|
||||
case "compaction_end":
|
||||
case "auto_retry_start":
|
||||
case "auto_retry_end":
|
||||
case "summarization_retry_scheduled":
|
||||
case "summarization_retry_attempt_start":
|
||||
case "summarization_retry_finished":
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-custom-provider",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-anthropic",
|
||||
"private": true,
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||
"private": true,
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-gondolin",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.12.0"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-gondolin",
|
||||
"private": true,
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-sandbox",
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-sandbox",
|
||||
"private": true,
|
||||
"version": "1.11.0",
|
||||
"version": "1.11.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pi-extension-with-deps",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "pi-extension-with-deps",
|
||||
"private": true,
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"clean": "echo 'nothing to clean'",
|
||||
|
||||
+15
-15
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.81.0"
|
||||
"@earendil-works/pi-coding-agent": "0.81.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -450,11 +450,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
@@ -465,8 +465,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
@@ -489,13 +489,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-coding-agent": {
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0.tgz",
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-tui": "^0.81.0",
|
||||
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-tui": "^0.81.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -523,8 +523,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent-install",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"private": true,
|
||||
"description": "Lockfile root used by the Pi installer and updater.",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.81.0"
|
||||
"@earendil-works/pi-coding-agent": "0.81.1"
|
||||
},
|
||||
"overrides": {
|
||||
"rimraf": "6.1.2",
|
||||
|
||||
+12
-12
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@earendil-works/pi-coding-agent",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-tui": "^0.81.0",
|
||||
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-tui": "^0.81.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
@@ -474,11 +474,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-agent-core": {
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0.tgz",
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"diff": "8.0.4",
|
||||
"ignore": "7.0.5",
|
||||
"typebox": "1.1.38",
|
||||
@@ -489,8 +489,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-ai": {
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0.tgz",
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.91.1",
|
||||
@@ -513,8 +513,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/pi-tui": {
|
||||
"version": "0.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0.tgz",
|
||||
"version": "0.81.1",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.1.tgz",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "1.6.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"piConfig": {
|
||||
@@ -39,9 +39,9 @@
|
||||
"prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-agent-core": "^0.81.0",
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-tui": "^0.81.0",
|
||||
"@earendil-works/pi-agent-core": "^0.81.1",
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-tui": "^0.81.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"chalk": "5.6.2",
|
||||
"cross-spawn": "7.0.6",
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
isContextOverflow,
|
||||
isRetryableAssistantError,
|
||||
modelsAreEqual,
|
||||
type RetryCallbacks,
|
||||
resetApiProviders,
|
||||
streamSimple,
|
||||
} from "@earendil-works/pi-ai/compat";
|
||||
@@ -161,7 +162,21 @@ export type AgentSessionEvent =
|
||||
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 */
|
||||
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
||||
@@ -1838,6 +1853,8 @@ export class AgentSession {
|
||||
this.thinkingLevel,
|
||||
this.agent.streamFunction,
|
||||
env,
|
||||
this.settingsManager.getRetrySettings(),
|
||||
this._summarizationRetryCallbacks({ source: "compaction", reason: "manual" }),
|
||||
);
|
||||
summary = result.summary;
|
||||
firstKeptEntryId = result.firstKeptEntryId;
|
||||
@@ -2114,6 +2131,8 @@ export class AgentSession {
|
||||
this.thinkingLevel,
|
||||
this.agent.streamFunction,
|
||||
env,
|
||||
this.settingsManager.getRetrySettings(),
|
||||
this._summarizationRetryCallbacks({ source: "compaction", reason }),
|
||||
);
|
||||
summary = compactResult.summary;
|
||||
firstKeptEntryId = compactResult.firstKeptEntryId;
|
||||
@@ -2620,6 +2639,37 @@ export class AgentSession {
|
||||
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.
|
||||
* @returns true if the caller should continue the agent, false otherwise
|
||||
@@ -2934,6 +2984,8 @@ export class AgentSession {
|
||||
replaceInstructions,
|
||||
reserveTokens: branchSummarySettings.reserveTokens,
|
||||
streamFn: this.agent.streamFunction,
|
||||
retry: this.settingsManager.getRetrySettings(),
|
||||
callbacks: this._summarizationRetryCallbacks({ source: "branchSummary" }),
|
||||
});
|
||||
if (result.aborted) {
|
||||
return { cancelled: true, aborted: true };
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
*/
|
||||
|
||||
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 type { Model, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai/compat";
|
||||
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
||||
import {
|
||||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
createCustomMessage,
|
||||
} from "../messages.ts";
|
||||
import type { ReadonlySessionManager, SessionEntry } from "../session-manager.ts";
|
||||
import { estimateTokens } from "./compaction.ts";
|
||||
import { completeSummarization, estimateTokens } from "./compaction.ts";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
@@ -83,6 +83,10 @@ export interface GenerateBranchSummaryOptions {
|
||||
reserveTokens?: number;
|
||||
/** Optional session stream function. Used to preserve SDK request behavior without mutating agent state. */
|
||||
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,
|
||||
reserveTokens = 16384,
|
||||
streamFn,
|
||||
retry,
|
||||
callbacks,
|
||||
} = options;
|
||||
|
||||
// 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
|
||||
// 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 requestOptions: SimpleStreamOptions = { apiKey, headers, env, signal, maxTokens: 2048 };
|
||||
const response = streamFn
|
||||
? await (await streamFn(model, context, requestOptions)).result()
|
||||
: await completeSimple(model, context, requestOptions);
|
||||
const response = await completeSummarization(model, context, requestOptions, streamFn, retry, callbacks);
|
||||
|
||||
// Check if aborted or errored
|
||||
if (response.stopReason === "aborted") {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
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 { completeSimple } from "@earendil-works/pi-ai/compat";
|
||||
import { convertToLlm } from "../messages.ts";
|
||||
@@ -552,17 +552,24 @@ function createSummarizationOptions(
|
||||
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>,
|
||||
context: Context,
|
||||
options: SimpleStreamOptions,
|
||||
streamFn?: StreamFn,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<AssistantMessage> {
|
||||
if (!streamFn) {
|
||||
return completeSimple(model, context, options);
|
||||
}
|
||||
const stream = await streamFn(model, context, options);
|
||||
return stream.result();
|
||||
const produce = async (): Promise<AssistantMessage> =>
|
||||
streamFn ? (await streamFn(model, context, options)).result() : completeSimple(model, context, options);
|
||||
return retryAssistantCall(produce, retry, options.signal, callbacks);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -581,6 +588,8 @@ export async function generateSummary(
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
env?: Record<string, string>,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<string> {
|
||||
return (
|
||||
await generateSummaryWithUsage(
|
||||
@@ -595,6 +604,8 @@ export async function generateSummary(
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
env,
|
||||
retry,
|
||||
callbacks,
|
||||
)
|
||||
).text;
|
||||
}
|
||||
@@ -612,6 +623,8 @@ export async function generateSummaryWithUsage(
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
env?: Record<string, string>,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<{ text: string; usage: Usage }> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.8 * reserveTokens),
|
||||
@@ -651,6 +664,8 @@ export async function generateSummaryWithUsage(
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
completionOptions,
|
||||
streamFn,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
@@ -801,6 +816,8 @@ export async function compact(
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
env?: Record<string, string>,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<CompactionResult> {
|
||||
const {
|
||||
firstKeptEntryId,
|
||||
@@ -833,6 +850,8 @@ export async function compact(
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
env,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
historyText = historyResult.text;
|
||||
historyUsage = historyResult.usage;
|
||||
@@ -847,6 +866,8 @@ export async function compact(
|
||||
signal,
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
// Merge into single summary
|
||||
summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`;
|
||||
@@ -865,6 +886,8 @@ export async function compact(
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
env,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
summary = result.text;
|
||||
summaryUsage = result.usage;
|
||||
@@ -900,6 +923,8 @@ async function generateTurnPrefixSummary(
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
retry?: RetryPolicy,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<{ text: string; usage: Usage }> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.5 * reserveTokens),
|
||||
@@ -921,6 +946,8 @@ async function generateTurnPrefixSummary(
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
createSummarizationOptions(model, maxTokens, apiKey, headers, env, signal, thinkingLevel),
|
||||
streamFn,
|
||||
retry,
|
||||
callbacks,
|
||||
);
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { join } from "node:path";
|
||||
import { Agent, type AgentMessage, type ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { clampThinkingLevel, type Message, type Model } from "@earendil-works/pi-ai/compat";
|
||||
import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat";
|
||||
import { getAgentDir } from "../config.ts";
|
||||
import { resolvePath } from "../utils/paths.ts";
|
||||
import { AgentSession } from "./agent-session.ts";
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
withFileMutationQueue,
|
||||
} 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 {
|
||||
/** Working directory for project-local discovery. Default: process.cwd() */
|
||||
cwd?: string;
|
||||
@@ -294,7 +299,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
tools: [],
|
||||
},
|
||||
convertToLlm: convertToLlmWithBlockImages,
|
||||
streamFunction: async (model, context, options) => {
|
||||
streamFn: async (model, context, options) => {
|
||||
const providerRetrySettings = settingsManager.getProviderRetrySettings();
|
||||
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
|
||||
// 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);
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
|
||||
@@ -826,6 +826,13 @@ export class InteractiveMode {
|
||||
async run(): Promise<void> {
|
||||
await this.init();
|
||||
|
||||
if (!process.env.PI_OFFLINE) {
|
||||
void this.session.modelRuntime
|
||||
.refresh()
|
||||
.then(() => this.updateAvailableProviderCount())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Start version check asynchronously
|
||||
checkForNewPiVersion(this.version).then((newRelease) => {
|
||||
if (newRelease) {
|
||||
@@ -3110,6 +3117,32 @@ export class InteractiveMode {
|
||||
this.ui.requestRender();
|
||||
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 */
|
||||
private async updateAvailableProviderCount(): Promise<void> {
|
||||
const models = await this.getModelCandidates();
|
||||
const uniqueProviders = new Set(models.map((m) => m.provider));
|
||||
/** Update the footer's available provider count from the current snapshot without refreshing catalogs. */
|
||||
private updateAvailableProviderCount(): void {
|
||||
const models =
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("AgentSession auto-compaction queue resume", () => {
|
||||
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "Test",
|
||||
|
||||
@@ -49,7 +49,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
|
||||
const model = getModel("anthropic", "claude-sonnet-4-5")!;
|
||||
const agent = new Agent({
|
||||
getApiKey: () => API_KEY,
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||
|
||||
@@ -90,7 +90,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFunction: (_model, _context, options) => {
|
||||
streamFn: (_model, _context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
@@ -195,7 +195,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFunction: (_model, context, options) => {
|
||||
streamFn: (_model, context, options) => {
|
||||
abortSignal = options?.signal;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
@@ -301,7 +301,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
@@ -362,7 +362,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
systemPrompt: "Test",
|
||||
tools: [tool],
|
||||
},
|
||||
streamFunction: async (_model, context) => {
|
||||
streamFn: async (_model, context) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const toolResultCount = context.messages.filter((message) => message.role === "toolResult").length;
|
||||
@@ -508,7 +508,7 @@ describe("AgentSession concurrent prompt guard", () => {
|
||||
systemPrompt: "Test",
|
||||
tools: [tool],
|
||||
},
|
||||
streamFunction: async (_model, context) => {
|
||||
streamFn: async (_model, context) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
const hasToolResult = context.messages.some((message) => message.role === "toolResult");
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("AgentSession retry", () => {
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
callCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
@@ -203,7 +203,7 @@ describe("AgentSession retry", () => {
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFunction: streamFn,
|
||||
streamFn: streamFn,
|
||||
});
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const settingsManager = SettingsManager.create(tempDir, tempDir);
|
||||
@@ -255,7 +255,7 @@ describe("AgentSession retry", () => {
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
initialState: { model, systemPrompt: "Test", tools: [] },
|
||||
streamFunction: () => {
|
||||
streamFn: () => {
|
||||
callCount++;
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
|
||||
@@ -75,7 +75,7 @@ async function createSession() {
|
||||
const session = new AgentSession({
|
||||
agent: new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
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 agent = new Agent({
|
||||
getApiKey: () => API_KEY,
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
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-2")).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 () => {
|
||||
writeModelsJson({
|
||||
|
||||
@@ -114,7 +114,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs:
|
||||
systemPrompt: "Test",
|
||||
tools: [],
|
||||
},
|
||||
streamFunction: (_model, _context, _options) => {
|
||||
streamFn: (_model, _context, _options) => {
|
||||
const stream = new MockAssistantStream();
|
||||
queueMicrotask(() => {
|
||||
stream.push({ type: "start", partial: createAssistantMessage("") });
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => (withConfiguredAuth ? "faux-key" : undefined),
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: options.systemPrompt ?? "You are a test assistant.",
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("regression #5596: missing configured theme export", () => {
|
||||
tools: [],
|
||||
},
|
||||
convertToLlm,
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
});
|
||||
const session = new AgentSession({
|
||||
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.",
|
||||
tools: options.tools ?? [],
|
||||
},
|
||||
streamFunction: streamFn,
|
||||
streamFn: streamFn,
|
||||
});
|
||||
|
||||
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.",
|
||||
tools: createCodingTools(process.cwd()),
|
||||
},
|
||||
streamFunction: streamSimple,
|
||||
streamFn: streamSimple,
|
||||
});
|
||||
|
||||
const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
|
||||
## [0.81.0] - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-server",
|
||||
"version": "0.81.0",
|
||||
"version": "0.81.1",
|
||||
"description": "experimental server package for pi",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -40,7 +40,7 @@
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "^0.81.0"
|
||||
"@earendil-works/pi-coding-agent": "^0.81.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shx": "0.4.0"
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
|
||||
## [0.81.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -32,7 +32,7 @@
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-ai": "^0.81.0",
|
||||
"@earendil-works/pi-agent-core": "^0.81.0"
|
||||
"@earendil-works/pi-ai": "^0.81.1",
|
||||
"@earendil-works/pi-agent-core": "^0.81.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.81.1] - 2026-07-21
|
||||
|
||||
## [0.81.0] - 2026-07-21
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -9,5 +9,5 @@ if (!model) throw new Error("Anthropic smoke-test model not found");
|
||||
|
||||
export const agent = new Agent({
|
||||
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 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 });
|
||||
const repo = new InMemorySessionRepo();
|
||||
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
|
||||
|
||||
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 { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
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
|
||||
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:
|
||||
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();
|
||||
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();
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -53,6 +58,8 @@ const temporaryRoot = mkdtempSync(join(tmpdir(), "pi-model-catalog-diff-"));
|
||||
const baselineWorktree = join(temporaryRoot, "baseline-worktree");
|
||||
const baselineOutput = join(temporaryRoot, "before");
|
||||
const currentOutput = join(temporaryRoot, "after");
|
||||
const baselineThinkingOutput = join(temporaryRoot, "before-thinking");
|
||||
const currentThinkingOutput = join(temporaryRoot, "after-thinking");
|
||||
let worktreeAdded = false;
|
||||
|
||||
function generateCatalog(cwd, outputDir, pretty = false) {
|
||||
@@ -76,8 +83,54 @@ function readProviderCatalog(outputDir, provider) {
|
||||
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) {
|
||||
writeFileSync(path, model === undefined ? "" : `${JSON.stringify(model, null, 2)}\n`);
|
||||
writeFileSync(path, model === undefined ? "" : `${formatJsonForDiff(canonicalizeJson(model))}\n`);
|
||||
}
|
||||
|
||||
function writeChangedLines(output) {
|
||||
@@ -94,6 +147,10 @@ function writeChangedLines(output) {
|
||||
try {
|
||||
run("git", ["worktree", "add", "--detach", baselineWorktree, "HEAD"], { cwd: repoRoot });
|
||||
worktreeAdded = true;
|
||||
copyFileSync(
|
||||
join(repoRoot, "scripts", "generate-thinking-capabilities.mjs"),
|
||||
join(baselineWorktree, "scripts", "generate-thinking-capabilities.mjs"),
|
||||
);
|
||||
|
||||
const nodeModules = join(repoRoot, "node_modules");
|
||||
if (existsSync(nodeModules)) {
|
||||
@@ -107,17 +164,26 @@ try {
|
||||
generateCatalog(repoRoot, currentOutput, true);
|
||||
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 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 afterModelPath = "after-model.json";
|
||||
const changedModels = [];
|
||||
let differences = 0;
|
||||
|
||||
for (const provider of providers) {
|
||||
const beforeModels = readProviderCatalog(baselineOutput, provider);
|
||||
const afterModels = readProviderCatalog(currentOutput, provider);
|
||||
const beforeModels = readProviderCatalog(beforeCatalogOutput, provider);
|
||||
const afterModels = readProviderCatalog(currentCatalogOutput, provider);
|
||||
if (beforeModels === undefined && afterModels === undefined) {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
@@ -126,7 +192,7 @@ try {
|
||||
for (const modelId of modelIds) {
|
||||
const beforeModel = beforeModels?.[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, afterModelPath), afterModel);
|
||||
@@ -156,7 +222,7 @@ try {
|
||||
}
|
||||
|
||||
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 {
|
||||
console.log(`\n${differences} model catalog entr${differences === 1 ? "y" : "ies"} changed.`);
|
||||
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