diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index dc3961ec9a9..c69fbdbafe2 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -279,3 +279,11 @@ ananthakumaran pr andrebreijao pr anh-chu pr + +rsaryev pr + +QuintinShaw pr + +R-Taneja pr + +zaycruz pr diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 43cb73c5730..b486675bde3 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -5,9 +5,9 @@ body: - type: markdown attributes: value: | - **Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md). + **Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md). - New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. + New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. diff --git a/.github/ISSUE_TEMPLATE/contribution.yml b/.github/ISSUE_TEMPLATE/contribution.yml index 0cf6c5be4bf..1a36a1a35b9 100644 --- a/.github/ISSUE_TEMPLATE/contribution.yml +++ b/.github/ISSUE_TEMPLATE/contribution.yml @@ -5,9 +5,9 @@ body: - type: markdown attributes: value: | - **Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md). + **Before you start:** Read [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md). - New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. + New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. diff --git a/.github/ISSUE_TEMPLATE/package-report.yml b/.github/ISSUE_TEMPLATE/package-report.yml index 846e25ee5c8..dfd5c80d472 100644 --- a/.github/ISSUE_TEMPLATE/package-report.yml +++ b/.github/ISSUE_TEMPLATE/package-report.yml @@ -7,7 +7,7 @@ body: value: | Use this form to report a package listed on pi.dev. For Pi core bugs, use the bug report template instead. - New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi-mono/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. + New issues from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. Issues that do not meet the quality bar in [CONTRIBUTING.md](https://github.com/earendil-works/pi/blob/main/CONTRIBUTING.md) will not be reopened or receive a reply. Keep this short. If it doesn't fit on one screen, it's too long. Write in your own voice. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a40a894cccf..ce0cfef9e29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm diff --git a/.github/workflows/issue-analysis.yml b/.github/workflows/issue-analysis.yml index c4d0a1fb520..679fb3f476f 100644 --- a/.github/workflows/issue-analysis.yml +++ b/.github/workflows/issue-analysis.yml @@ -61,7 +61,7 @@ jobs: steps: - name: Verify sender permission id: verify - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: ORG_READ_TOKEN: ${{ secrets.EARENDIL_ORG_READ_TOKEN }} with: @@ -254,19 +254,19 @@ jobs: steps: - name: Create high-entropy working directory name id: workdir - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const crypto = require('crypto'); core.setOutput('name', `pi-ci-${crypto.randomBytes(16).toString('hex')}`); - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: ${{ steps.workdir.outputs.name }} - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 cache: npm @@ -319,7 +319,7 @@ jobs: - name: Write auth.json id: write_auth - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PI_AUTH_JSON: ${{ secrets.PI_AUTH_JSON }} with: @@ -341,7 +341,7 @@ jobs: } - name: Run pi /is - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent GH_TOKEN: ${{ github.token }} @@ -404,7 +404,7 @@ jobs: - name: Persist refreshed auth.json if: always() && steps.write_auth.outcome == 'success' - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GH_TOKEN: ${{ secrets.PI_AUTH_UPDATE_TOKEN }} PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent @@ -457,7 +457,7 @@ jobs: - name: Export session files id: export_session_files if: always() - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PI_CODING_AGENT_DIR: ${{ runner.temp }}/pi-agent WORKDIR: ${{ steps.workdir.outputs.name }} @@ -509,7 +509,7 @@ jobs: - name: Upload session gist id: gist if: always() && steps.export_session_files.outcome == 'success' - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: PI_GIST_TOKEN: ${{ secrets.PI_GIST_TOKEN }} with: @@ -540,7 +540,7 @@ jobs: - name: Comment with session import instructions if: always() && steps.gist.outcome == 'success' - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GIST_URL: ${{ steps.gist.outputs.url }} GIST_ID: ${{ steps.gist.outputs.id }} @@ -619,7 +619,7 @@ jobs: - name: Remove trigger label if: always() - uses: actions/github-script@v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | try { diff --git a/.gitignore b/.gitignore index 8cfb05c71eb..0e241a9dc3e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ packages/*/dist/ packages/*/dist-chrome/ packages/*/dist-firefox/ packages/ai/src/providers/data/ +packages/ai/src/providers/.model-generation-*/ *.cpuprofile # Environment diff --git a/README.md b/README.md index 130a412f093..900779b0ab4 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.m ```bash npm install --ignore-scripts # Install all dependencies without running lifecycle scripts -npm run build # Build all packages -npm run check # Lint, format, and type check +npm run build # Refresh model data, then build all packages +npm run build:offline # Rebuild using existing model data without network access +npm run check # Lint, format, and type check ./test.sh # Run tests (skips LLM-dependent tests without API keys) ./pi-test.sh # Run pi from sources (can be run from any directory) ``` diff --git a/biome.json b/biome.json index b545140290e..ef3cec421eb 100644 --- a/biome.json +++ b/biome.json @@ -27,6 +27,8 @@ "includes": [ "packages/*/src/**/*.ts", "packages/*/test/**/*.ts", + "packages/storage/*/src/**/*.ts", + "packages/storage/*/test/**/*.ts", "packages/coding-agent/examples/**/*.ts", "!**/node_modules/**/*", "!**/test-sessions.ts", diff --git a/package-lock.json b/package-lock.json index a51b915dfad..e8b33ba38f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.3", "workspaces": [ "packages/*", + "packages/storage/*", "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", @@ -793,8 +794,12 @@ "resolved": "packages/coding-agent", "link": true }, - "node_modules/@earendil-works/pi-orchestrator": { - "resolved": "packages/orchestrator", + "node_modules/@earendil-works/pi-server": { + "resolved": "packages/server", + "link": true + }, + "node_modules/@earendil-works/pi-storage-sqlite-node": { + "resolved": "packages/storage/sqlite-node", "link": true }, "node_modules/@earendil-works/pi-tui": { @@ -2385,9 +2390,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -3345,7 +3350,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5043,10 +5047,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "0.80.10-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -5398,7 +5402,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -5705,12 +5709,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "0.80.10-kkl.1", - "@earendil-works/pi-ai": "0.80.10-kkl.1", - "@earendil-works/pi-tui": "0.80.10-kkl.1", + "@earendil-works/pi-agent-core": "0.81.0-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1", + "@earendil-works/pi-tui": "0.81.0-kkl.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5751,32 +5755,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.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.80.10-kkl.1" + "version": "0.81.0-kkl.1" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "dependencies": { "ms": "2.1.3" }, @@ -6071,12 +6075,15 @@ } } }, - "packages/orchestrator": { - "name": "@earendil-works/pi-orchestrator", - "version": "0.80.10-kkl.1", + "packages/server": { + "name": "@earendil-works/pi-server", + "version": "0.81.0-kkl.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.10-kkl.1" + "@earendil-works/pi-coding-agent": "0.81.0-kkl.1" + }, + "bin": { + "server": "dist/cli.js" }, "devDependencies": { "shx": "0.4.0" @@ -6085,9 +6092,21 @@ "node": ">=22.19.0" } }, + "packages/storage/sqlite-node": { + "name": "@earendil-works/pi-storage-sqlite-node", + "version": "0.81.0-kkl.1", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "0.81.0-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/package.json b/package.json index c919d33e7e1..4f710af98ab 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "workspaces": [ "packages/*", + "packages/storage/*", "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", @@ -12,7 +13,8 @@ ], "scripts": { "clean": "npm run clean --workspaces", - "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../orchestrator && npm run build", + "build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../coding-agent && npm run build && cd ../server && npm run build", + "build:offline": "cd packages/tui && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../storage/sqlite-node && npm run build && cd ../../coding-agent && npm run build && cd ../server && npm run build", "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:pinned-deps": "node scripts/check-pinned-deps.mjs", @@ -20,6 +22,8 @@ "check:install-lock:coding-agent": "node scripts/generate-coding-agent-install-lock.mjs --check", "check:ts-imports": "node scripts/check-ts-relative-imports.mjs", "generate:models": "npm --prefix packages/ai run generate-models && npm --prefix packages/ai run generate-image-models", + "hydrate:model-data": "npm --prefix packages/ai run hydrate-model-data", + "check:model-data": "npm --prefix packages/ai run check:model-data", "generate:model-catalog": "npm --prefix packages/ai run generate-model-catalog", "diff:model-catalog": "node scripts/diff-model-catalog.mjs", "check:model-catalog": "node scripts/publish-model-catalog.mjs --input .artifacts/model-catalog --dry-run", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index b27954ca931..0e578ff615f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [0.81.0] - 2026-07-21 + +### Breaking Changes + +- Changed `SessionStorage` to use `getPathToRootOrCompaction()`, require session name and statistics methods, support cursor-based entry reads, and store retained compaction tails as self-contained checkpoints ([#6594](https://github.com/earendil-works/pi/pull/6594) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Moved the `uuidv7` export to `@earendil-works/pi-ai` ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)). +- Replaced the optional `Agent` `streamFn` fallback with a required `streamFunction` and made low-level loop stream functions required, preventing `@earendil-works/pi-ai/compat` and all built-in providers from entering selective-provider bundles ([#6851](https://github.com/earendil-works/pi/issues/6851)). + +### Added + +- Added usage metadata to tool results, compaction entries, and branch summaries in the agent harness ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)). + ## [0.80.10] - 2026-07-16 ## [0.80.9] - 2026-07-16 diff --git a/packages/agent/README.md b/packages/agent/README.md index 17eecea5798..543f4c8f0bc 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -8,17 +8,28 @@ Stateful agent with tool execution and event streaming. Built on `@earendil-work npm install @earendil-works/pi-agent-core ``` +### SQLite session backends + +The SQLite session backend and the `node:sqlite` adapter live in a separate package, `@earendil-works/pi-storage-sqlite-node`, so the core package does not pull in runtime builtins or native SQLite dependencies by default. The backend accepts a runtime-specific SQLite factory, allowing other storage backends to ship as their own packages in the future. + ## Quick Start ```typescript import { Agent } from "@earendil-works/pi-agent-core"; -import { getModel } from "@earendil-works/pi-ai"; +import { createModels } from "@earendil-works/pi-ai"; +import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic"; + +const models = createModels(); +models.setProvider(anthropicProvider()); +const model = models.getModel("anthropic", "claude-sonnet-4-6"); +if (!model) throw new Error("Model not found"); const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant.", - model: getModel("anthropic", "claude-sonnet-4-20250514"), + model, }, + streamFunction: models.streamSimple.bind(models), }); agent.subscribe((event) => { @@ -115,13 +126,19 @@ Tools can also return `terminate: true` to hint that the automatic follow-up LLM Low-level loop callers can set `shouldStopAfterTurn` to stop gracefully after the current turn completes: ```typescript -const stream = agentLoop(prompts, context, { - model, - convertToLlm, - shouldStopAfterTurn: async ({ message, toolResults, context, newMessages }) => { - return shouldCompactBeforeNextTurn(context.messages); +const stream = agentLoop( + prompts, + context, + { + model, + convertToLlm, + shouldStopAfterTurn: async ({ message, toolResults, context, newMessages }) => { + return shouldCompactBeforeNextTurn(context.messages); + }, }, -}); + undefined, + models.streamSimple.bind(models), +); ``` `shouldStopAfterTurn` runs after `turn_end` is emitted and after the assistant response and any tool executions have completed normally. If it returns `true`, the loop emits `agent_end` and exits before polling steering or follow-up queues, and before starting another LLM call. It does not abort the provider stream, does not cancel running tools, and does not alter the assistant message stop reason. @@ -181,8 +198,8 @@ const agent = new Agent({ // Follow-up mode: "one-at-a-time" (default) or "all" followUpMode: "one-at-a-time", - // Custom stream function (for proxy backends) - streamFn: streamProxy, + // Required stream function + streamFunction: models.streamSimple.bind(models), // Session ID for provider caching sessionId: "session-123", @@ -369,6 +386,7 @@ Handle custom types in `convertToLlm`: ```typescript const agent = new Agent({ + streamFunction: models.streamSimple.bind(models), convertToLlm: (messages) => messages.flatMap(m => { if (m.role === "notification") return []; // Filter out return [m]; @@ -439,7 +457,7 @@ For browser apps that proxy through a backend: import { Agent, streamProxy } from "@earendil-works/pi-agent-core"; const agent = new Agent({ - streamFn: (model, context, options) => + streamFunction: (model, context, options) => streamProxy(model, context, { ...options, authToken: "...", @@ -471,12 +489,13 @@ const config: AgentLoopConfig = { const userMessage = { role: "user", content: "Hello", timestamp: Date.now() }; -for await (const event of agentLoop([userMessage], context, config)) { +const streamFunction = models.streamSimple.bind(models); +for await (const event of agentLoop([userMessage], context, config, undefined, streamFunction)) { console.log(event.type); } // Continue from existing context -for await (const event of agentLoopContinue(context, config)) { +for await (const event of agentLoopContinue(context, config, undefined, streamFunction)) { console.log(event.type); } ``` diff --git a/packages/agent/package.json b/packages/agent/package.json index ad89c8f44c1..af07b2615f9 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -26,10 +26,10 @@ "test": "vitest --run", "test:harness": "vitest --run --config vitest.harness.config.ts", "coverage:harness": "vitest --run --config vitest.harness.config.ts --coverage", - "prepublishOnly": "npm run clean && npm run build" + "prepublishOnly": "npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "0.80.10-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 14eb9f6e14e..fff06996905 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -7,10 +7,9 @@ import { type AssistantMessage, type Context, EventStream, - streamSimple, type ToolResultMessage, validateToolArguments, -} from "@earendil-works/pi-ai/compat"; +} from "@earendil-works/pi-ai"; import type { AgentContext, AgentEvent, @@ -32,8 +31,8 @@ export function agentLoop( prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, - signal?: AbortSignal, - streamFn?: StreamFn, + signal: AbortSignal | undefined, + streamFunction: StreamFn, ): EventStream { const stream = createAgentStream(); @@ -45,7 +44,7 @@ export function agentLoop( stream.push(event); }, signal, - streamFn, + streamFunction, ).then((messages) => { stream.end(messages); }); @@ -64,8 +63,8 @@ export function agentLoop( export function agentLoopContinue( context: AgentContext, config: AgentLoopConfig, - signal?: AbortSignal, - streamFn?: StreamFn, + signal: AbortSignal | undefined, + streamFunction: StreamFn, ): EventStream { if (context.messages.length === 0) { throw new Error("Cannot continue: no messages in context"); @@ -84,7 +83,7 @@ export function agentLoopContinue( stream.push(event); }, signal, - streamFn, + streamFunction, ).then((messages) => { stream.end(messages); }); @@ -97,8 +96,8 @@ export async function runAgentLoop( context: AgentContext, config: AgentLoopConfig, emit: AgentEventSink, - signal?: AbortSignal, - streamFn?: StreamFn, + signal: AbortSignal | undefined, + streamFunction: StreamFn, ): Promise { const newMessages: AgentMessage[] = [...prompts]; const currentContext: AgentContext = { @@ -113,7 +112,7 @@ export async function runAgentLoop( await emit({ type: "message_end", message: prompt }); } - await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + await runLoop(currentContext, newMessages, config, signal, emit, streamFunction); return newMessages; } @@ -121,8 +120,8 @@ export async function runAgentLoopContinue( context: AgentContext, config: AgentLoopConfig, emit: AgentEventSink, - signal?: AbortSignal, - streamFn?: StreamFn, + signal: AbortSignal | undefined, + streamFunction: StreamFn, ): Promise { if (context.messages.length === 0) { throw new Error("Cannot continue: no messages in context"); @@ -138,7 +137,7 @@ export async function runAgentLoopContinue( await emit({ type: "agent_start" }); await emit({ type: "turn_start" }); - await runLoop(currentContext, newMessages, config, signal, emit, streamFn); + await runLoop(currentContext, newMessages, config, signal, emit, streamFunction); return newMessages; } @@ -158,7 +157,7 @@ async function runLoop( initialConfig: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink, - streamFn?: StreamFn, + streamFunction: StreamFn, ): Promise { let currentContext = initialContext; let config = initialConfig; @@ -190,7 +189,7 @@ async function runLoop( } // Stream assistant response - const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn); + const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction); newMessages.push(message); if (message.stopReason === "error" || message.stopReason === "aborted") { @@ -283,7 +282,7 @@ async function streamAssistantResponse( config: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink, - streamFn?: StreamFn, + streamFunction: StreamFn, ): Promise { // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; @@ -301,8 +300,6 @@ async function streamAssistantResponse( tools: context.tools, }; - const streamFunction = streamFn || streamSimple; - // Resolve API key (important for expiring tokens) const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey; @@ -737,6 +734,7 @@ async function finalizeExecutedToolCall( ...result, content: afterResult.content ?? result.content, details: afterResult.details ?? result.details, + usage: afterResult.usage ?? result.usage, terminate: afterResult.terminate ?? result.terminate, }; isError = afterResult.isError ?? isError; @@ -780,6 +778,7 @@ function createToolResultMessage(finalized: FinalizedToolCallOutcome): ToolResul // so the null never enters session history or provider payloads. content: finalized.result.content ?? [], details: finalized.result.details, + usage: finalized.result.usage, ...(finalized.result.addedToolNames?.length ? { addedToolNames: finalized.result.addedToolNames } : {}), isError: finalized.isError, timestamp: Date.now(), diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ab10d5ac124..653adf8e893 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,13 +1,12 @@ -import { - type ImageContent, - type Message, - type Model, - type SimpleStreamOptions, - streamSimple, - type TextContent, - type ThinkingBudgets, - type Transport, -} from "@earendil-works/pi-ai/compat"; +import type { + ImageContent, + Message, + Model, + SimpleStreamOptions, + TextContent, + ThinkingBudgets, + Transport, +} from "@earendil-works/pi-ai"; import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts"; import type { AfterToolCallContext, @@ -98,7 +97,7 @@ export interface AgentOptions { initialState?: Partial>; convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - streamFn?: StreamFn; + streamFunction: StreamFn; getApiKey?: (provider: string) => Promise | string | undefined; onPayload?: SimpleStreamOptions["onPayload"]; onResponse?: SimpleStreamOptions["onResponse"]; @@ -176,7 +175,7 @@ export class Agent { public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; public transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - public streamFn: StreamFn; + public streamFunction: StreamFn; public getApiKey?: (provider: string) => Promise | string | undefined; public onPayload?: SimpleStreamOptions["onPayload"]; public onResponse?: SimpleStreamOptions["onResponse"]; @@ -207,11 +206,11 @@ export class Agent { /** Tool execution strategy for assistant messages that contain multiple tool calls. */ public toolExecution: ToolExecutionMode; - constructor(options: AgentOptions = {}) { + constructor(options: AgentOptions) { this._state = createMutableAgentState(options.initialState); this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm; this.transformContext = options.transformContext; - this.streamFn = options.streamFn ?? streamSimple; + this.streamFunction = options.streamFunction; this.getApiKey = options.getApiKey; this.onPayload = options.onPayload; this.onResponse = options.onResponse; @@ -404,7 +403,7 @@ export class Agent { this.createLoopConfig(options), (event) => this.processEvents(event), signal, - this.streamFn, + this.streamFunction, ); }); } @@ -416,7 +415,7 @@ export class Agent { this.createLoopConfig(), (event) => this.processEvents(event), signal, - this.streamFn, + this.streamFunction, ); }); } diff --git a/packages/agent/src/harness/agent-harness.ts b/packages/agent/src/harness/agent-harness.ts index 1d09b054e4b..5ec7d574294 100644 --- a/packages/agent/src/harness/agent-harness.ts +++ b/packages/agent/src/harness/agent-harness.ts @@ -1,4 +1,11 @@ -import type { AssistantMessage, ImageContent, Model, Models, UserMessage } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + contentText, + type ImageContent, + type Model, + type Models, + type UserMessage, +} from "@earendil-works/pi-ai"; import { runAgentLoop } from "../agent-loop.ts"; import type { AgentContext, @@ -25,6 +32,7 @@ import type { AgentHarnessResources, AgentHarnessStreamOptions, AgentHarnessStreamOptionsPatch, + CompactResult, ExecutionEnv, NavigateTreeResult, PendingSessionWrite, @@ -427,9 +435,16 @@ export class AgentHarness< content: result.content, details: result.details, isError, + usage: result.usage, }); return patch - ? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate } + ? { + content: patch.content, + details: patch.details, + isError: patch.isError, + usage: patch.usage, + terminate: patch.terminate, + } : undefined; }, prepareNextTurn: async () => { @@ -683,9 +698,7 @@ export class AgentHarness< } } - async compact( - customInstructions?: string, - ): Promise<{ summary: string; firstKeptEntryId: string; tokensBefore: number; details?: unknown }> { + async compact(customInstructions?: string): Promise { if (this.phase !== "idle") throw new AgentHarnessError("busy", "compact() requires idle harness"); this.phase = "compaction"; try { @@ -716,6 +729,8 @@ export class AgentHarness< result.tokensBefore, result.details, provided !== undefined, + result.usage, + result.retainedTail, ); const entry = await this.session.getEntry(entryId); if (entry?.type === "compaction") { @@ -757,6 +772,7 @@ export class AgentHarness< let summaryEntry: NavigateTreeResult["summaryEntry"]; let summaryText: string | undefined = hookResult?.summary?.summary; let summaryDetails: unknown = hookResult?.summary?.details; + let summaryUsage = hookResult?.summary?.usage; if (!summaryText && options?.summarize && entries.length > 0) { const model = this.model; if (!model) throw new AgentHarnessError("invalid_state", "No model set for branch summary"); @@ -772,6 +788,7 @@ export class AgentHarness< throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error); } summaryText = branchSummary.value.summary; + summaryUsage = branchSummary.value.usage; summaryDetails = { readFiles: branchSummary.value.readFiles, modifiedFiles: branchSummary.value.modifiedFiles, @@ -781,30 +798,22 @@ export class AgentHarness< let newLeafId: string | null; if (targetEntry.type === "message" && targetEntry.message.role === "user") { newLeafId = targetEntry.parentId; - const content = targetEntry.message.content; - editorText = - typeof content === "string" - ? content - : content - .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + editorText = contentText(targetEntry.message.content, ""); } else if (targetEntry.type === "custom_message") { newLeafId = targetEntry.parentId; - editorText = - typeof targetEntry.content === "string" - ? targetEntry.content - : targetEntry.content - .filter((c): c is { readonly type: "text"; readonly text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + editorText = contentText(targetEntry.content, ""); } else { newLeafId = targetId; } const summaryId = await this.session.moveTo( newLeafId, summaryText - ? { summary: summaryText, details: summaryDetails, fromHook: hookResult?.summary !== undefined } + ? { + summary: summaryText, + details: summaryDetails, + usage: summaryUsage, + fromHook: hookResult?.summary !== undefined, + } : undefined, ); if (summaryId) { diff --git a/packages/agent/src/harness/compaction/branch-summarization.ts b/packages/agent/src/harness/compaction/branch-summarization.ts index fdf1df4980d..ab56f3ac794 100644 --- a/packages/agent/src/harness/compaction/branch-summarization.ts +++ b/packages/agent/src/harness/compaction/branch-summarization.ts @@ -1,4 +1,4 @@ -import type { Model, Models } from "@earendil-works/pi-ai"; +import { contentText, type Model, type Models } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; import { @@ -245,16 +245,14 @@ export async function generateBranchSummary( ); } - let summary = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + let summary = contentText(response.content); summary = BRANCH_SUMMARY_PREAMBLE + summary; const { readFiles, modifiedFiles } = computeFileLists(fileOps); summary += formatFileOperations(readFiles, modifiedFiles); return ok({ summary: summary || "No summary generated", + usage: response.usage, readFiles, modifiedFiles, }); diff --git a/packages/agent/src/harness/compaction/compaction.ts b/packages/agent/src/harness/compaction/compaction.ts index 4132e3ddcf1..0dc82c7cd68 100644 --- a/packages/agent/src/harness/compaction/compaction.ts +++ b/packages/agent/src/harness/compaction/compaction.ts @@ -1,4 +1,12 @@ -import type { AssistantMessage, ImageContent, Model, Models, TextContent, Usage } from "@earendil-works/pi-ai"; +import { + type AssistantMessage, + contentText, + type ImageContent, + type Model, + type Models, + type TextContent, + type Usage, +} from "@earendil-works/pi-ai"; import type { AgentMessage, ThinkingLevel } from "../../types.ts"; import { convertToLlm, @@ -89,14 +97,41 @@ function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage export interface CompactionResult { /** Summary text that replaces compacted history in future context. */ summary: string; - /** Entry id where retained history starts. */ - firstKeptEntryId: string; + /** Entry id where retained history starts. Optional during Pi 2.0 transition. */ + firstKeptEntryId?: string; /** Estimated context tokens before compaction. */ tokensBefore: number; + /** Usage from the LLM call(s) that generated this summary, if available. */ + usage?: Usage; + /** Retained recent messages stored directly on the compaction entry. Optional during Pi 2.0 transition. */ + retainedTail?: AgentMessage[]; /** Optional implementation-specific details stored with the compaction entry. */ details?: T; } +function combineUsage(first: Usage, second: Usage): Usage { + return { + input: first.input + second.input, + output: first.output + second.output, + cacheRead: first.cacheRead + second.cacheRead, + cacheWrite: first.cacheWrite + second.cacheWrite, + ...(first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined + ? { cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) } + : {}), + ...(first.reasoning !== undefined || second.reasoning !== undefined + ? { reasoning: (first.reasoning ?? 0) + (second.reasoning ?? 0) } + : {}), + totalTokens: first.totalTokens + second.totalTokens, + cost: { + input: first.cost.input + second.cost.input, + output: first.cost.output + second.cost.output, + cacheRead: first.cost.cacheRead + second.cost.cacheRead, + cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite, + total: first.cost.total + second.cost.total, + }, + }; +} + /** Compaction thresholds and retention settings. */ export interface CompactionSettings { /** Enable automatic compaction decisions. */ @@ -467,6 +502,30 @@ export async function generateSummary( previousSummary?: string, thinkingLevel?: ThinkingLevel, ): Promise> { + const result = await generateSummaryWithUsage( + currentMessages, + models, + model, + reserveTokens, + signal, + customInstructions, + previousSummary, + thinkingLevel, + ); + return result.ok ? ok(result.value.text) : err(result.error); +} + +/** Generate or update a conversation summary and return its provider usage. */ +export async function generateSummaryWithUsage( + currentMessages: AgentMessage[], + models: Models, + model: Model, + reserveTokens: number, + signal?: AbortSignal, + customInstructions?: string, + previousSummary?: string, + thinkingLevel?: ThinkingLevel, +): Promise> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, @@ -513,12 +572,9 @@ export async function generateSummary( ); } - const textContent = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + const textContent = contentText(response.content); - return ok(textContent); + return ok({ text: textContent, usage: response.usage }); } /** Prepared inputs for a compaction run. */ @@ -529,6 +585,8 @@ export interface CompactionPreparation { messagesToSummarize: AgentMessage[]; /** Prefix messages summarized separately when compaction splits a turn. */ turnPrefixMessages: AgentMessage[]; + /** Recent messages retained after compaction and stored on the compaction entry. */ + retainedTail: AgentMessage[]; /** Whether compaction splits a turn. */ isSplitTurn: boolean; /** Estimated context tokens before compaction. */ @@ -563,7 +621,9 @@ export function prepareCompaction( if (prevCompactionIndex >= 0) { const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry; previousSummary = prevCompaction.summary; - const firstKeptEntryIndex = pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId); + const firstKeptEntryIndex = prevCompaction.firstKeptEntryId + ? pathEntries.findIndex((entry) => entry.id === prevCompaction.firstKeptEntryId) + : -1; boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1; } const boundaryEnd = pathEntries.length; @@ -590,6 +650,11 @@ export function prepareCompaction( if (msg) turnPrefixMessages.push(msg); } } + const retainedTail: AgentMessage[] = []; + for (let i = cutPoint.firstKeptEntryIndex; i < boundaryEnd; i++) { + const msg = getMessageFromEntryForCompaction(pathEntries[i]); + if (msg) retainedTail.push(msg); + } const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex); if (cutPoint.isSplitTurn) { for (const msg of turnPrefixMessages) { @@ -601,6 +666,7 @@ export function prepareCompaction( firstKeptEntryId, messagesToSummarize, turnPrefixMessages, + retainedTail, isSplitTurn: cutPoint.isSplitTurn, tokensBefore, previousSummary, @@ -639,6 +705,7 @@ export async function compact( firstKeptEntryId, messagesToSummarize, turnPrefixMessages, + retainedTail, isSplitTurn, tokensBefore, previousSummary, @@ -651,22 +718,26 @@ export async function compact( } let summary: string; + let summaryUsage: Usage; if (isSplitTurn && turnPrefixMessages.length > 0) { - const historyResult = - messagesToSummarize.length > 0 - ? await generateSummary( - messagesToSummarize, - models, - model, - settings.reserveTokens, - signal, - customInstructions, - previousSummary, - thinkingLevel, - ) - : ok("No prior history."); - if (!historyResult.ok) return err(historyResult.error); + let historyText = "No prior history."; + let historyUsage: Usage | undefined; + if (messagesToSummarize.length > 0) { + const historyResult = await generateSummaryWithUsage( + messagesToSummarize, + models, + model, + settings.reserveTokens, + signal, + customInstructions, + previousSummary, + thinkingLevel, + ); + if (!historyResult.ok) return err(historyResult.error); + historyText = historyResult.value.text; + historyUsage = historyResult.value.usage; + } const turnPrefixResult = await generateTurnPrefixSummary( turnPrefixMessages, models, @@ -676,9 +747,12 @@ export async function compact( thinkingLevel, ); if (!turnPrefixResult.ok) return err(turnPrefixResult.error); - summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`; + summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value.text}`; + summaryUsage = historyUsage + ? combineUsage(historyUsage, turnPrefixResult.value.usage) + : turnPrefixResult.value.usage; } else { - const summaryResult = await generateSummary( + const summaryResult = await generateSummaryWithUsage( messagesToSummarize, models, model, @@ -689,7 +763,8 @@ export async function compact( thinkingLevel, ); if (!summaryResult.ok) return err(summaryResult.error); - summary = summaryResult.value; + summary = summaryResult.value.text; + summaryUsage = summaryResult.value.usage; } const { readFiles, modifiedFiles } = computeFileLists(fileOps); @@ -699,6 +774,8 @@ export async function compact( summary, firstKeptEntryId, tokensBefore, + usage: summaryUsage, + retainedTail, details: { readFiles, modifiedFiles } as CompactionDetails, }); } @@ -709,7 +786,7 @@ async function generateTurnPrefixSummary( reserveTokens: number, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, -): Promise> { +): Promise> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, @@ -744,10 +821,8 @@ async function generateTurnPrefixSummary( ); } - return ok( - response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"), - ); + return ok({ + text: contentText(response.content), + usage: response.usage, + }); } diff --git a/packages/agent/src/harness/compaction/utils.ts b/packages/agent/src/harness/compaction/utils.ts index 07535b79bc7..7fd3aac0261 100644 --- a/packages/agent/src/harness/compaction/utils.ts +++ b/packages/agent/src/harness/compaction/utils.ts @@ -1,4 +1,4 @@ -import type { Message } from "@earendil-works/pi-ai"; +import { contentText, type Message } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; /** File paths touched by a session branch or compaction range. */ @@ -93,23 +93,14 @@ export function serializeConversation(messages: Message[]): string { for (const msg of messages) { if (msg.role === "user") { - const content = - typeof msg.content === "string" - ? msg.content - : msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + const content = contentText(msg.content, ""); if (content) parts.push(`[User]: ${content}`); } else if (msg.role === "assistant") { - const textParts: string[] = []; const thinkingParts: string[] = []; const toolCalls: string[] = []; for (const block of msg.content) { - if (block.type === "text") { - textParts.push(block.text); - } else if (block.type === "thinking") { + if (block.type === "thinking") { thinkingParts.push(block.thinking); } else if (block.type === "toolCall") { const args = block.arguments as Record; @@ -123,17 +114,14 @@ export function serializeConversation(messages: Message[]): string { if (thinkingParts.length > 0) { parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); } - if (textParts.length > 0) { - parts.push(`[Assistant]: ${textParts.join("\n")}`); + if (msg.content.some((block) => block.type === "text")) { + parts.push(`[Assistant]: ${contentText(msg.content)}`); } if (toolCalls.length > 0) { parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); } } else if (msg.role === "toolResult") { - const content = msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + const content = contentText(msg.content, ""); if (content) { parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); } diff --git a/packages/agent/src/harness/session/jsonl-storage.ts b/packages/agent/src/harness/session/jsonl-storage.ts index b411157266a..fea97fbdcfd 100644 --- a/packages/agent/src/harness/session/jsonl-storage.ts +++ b/packages/agent/src/harness/session/jsonl-storage.ts @@ -1,7 +1,14 @@ -import type { FileSystem, JsonlSessionMetadata, LeafEntry, SessionStorage, SessionTreeEntry } from "../types.ts"; +import { uuidv7 } from "@earendil-works/pi-ai"; +import type { + FileSystem, + JsonlSessionMetadata, + LeafEntry, + SessionEntryCursorOptions, + SessionStorage, + SessionTreeEntry, +} from "../types.ts"; import { SessionError, toError } from "../types.ts"; import { getFileSystemResultOrThrow } from "./repo-utils.ts"; -import { uuidv7 } from "./uuid.ts"; type JsonlSessionStorageFileSystem = Pick; @@ -293,13 +300,66 @@ export class JsonlSessionStorage implements SessionStorage return this.labelsById.get(id); } - async getPathToRoot(leafId: string | null): Promise { + async getSessionName(): Promise { + const entries = await this.findEntries("session_info"); + return entries[entries.length - 1]?.name?.trim() || undefined; + } + + async getSessionStats() { + let messageCount = 0; + let cachedTokens = 0; + let uncachedTokens = 0; + let totalTokens = 0; + let costTotal = 0; + for (const entry of this.entries) { + if (entry.type === "message") { + messageCount += 1; + } + const usage = + entry.type === "message" + ? entry.message.role === "assistant" + ? entry.message.usage + : undefined + : entry.type === "compaction" || entry.type === "branch_summary" + ? entry.usage + : undefined; + if ( + !usage || + typeof usage.input !== "number" || + typeof usage.output !== "number" || + typeof usage.cacheRead !== "number" || + typeof usage.cacheWrite !== "number" || + typeof usage.cost?.total !== "number" + ) { + continue; + } + cachedTokens += usage.cacheRead; + uncachedTokens += usage.input + usage.cacheWrite; + totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; + costTotal += usage.cost.total; + } + return { + messageCount, + cachedTokens, + uncachedTokens, + totalTokens, + costTotal, + }; + } + + async getPathToRootOrCompaction(leafId: string | null): Promise { if (leafId === null) return []; const path: SessionTreeEntry[] = []; + let stopAtEntryId: string | null = null; let current = this.byId.get(leafId); if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); while (current) { path.unshift(current); + if (stopAtEntryId !== null && current.id === stopAtEntryId) break; + if (current.type === "compaction") { + if (current.retainedTail) break; + stopAtEntryId = current.firstKeptEntryId ?? null; + } if (!current.parentId) break; const parent = this.byId.get(current.parentId); if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); @@ -308,7 +368,9 @@ export class JsonlSessionStorage implements SessionStorage return path; } - async getEntries(): Promise { - return [...this.entries]; + async getEntries(options?: SessionEntryCursorOptions): Promise { + const start = options?.afterEntrySeq ?? 0; + const end = options?.limit === undefined ? undefined : start + options.limit; + return this.entries.slice(start, end); } } diff --git a/packages/agent/src/harness/session/memory-storage.ts b/packages/agent/src/harness/session/memory-storage.ts index fba55dae543..1bc24f04f7a 100644 --- a/packages/agent/src/harness/session/memory-storage.ts +++ b/packages/agent/src/harness/session/memory-storage.ts @@ -1,11 +1,12 @@ +import { uuidv7 } from "@earendil-works/pi-ai"; import { type LeafEntry, + type SessionEntryCursorOptions, SessionError, type SessionMetadata, type SessionStorage, type SessionTreeEntry, } from "../types.ts"; -import { uuidv7 } from "./uuid.ts"; function updateLabelCache(labelsById: Map, entry: SessionTreeEntry): void { if (entry.type !== "label") return; @@ -112,13 +113,66 @@ export class InMemorySessionStorage { + async getSessionName(): Promise { + const entries = await this.findEntries("session_info"); + return entries[entries.length - 1]?.name?.trim() || undefined; + } + + async getSessionStats() { + let messageCount = 0; + let cachedTokens = 0; + let uncachedTokens = 0; + let totalTokens = 0; + let costTotal = 0; + for (const entry of this.entries) { + if (entry.type === "message") { + messageCount += 1; + } + const usage = + entry.type === "message" + ? entry.message.role === "assistant" + ? entry.message.usage + : undefined + : entry.type === "compaction" || entry.type === "branch_summary" + ? entry.usage + : undefined; + if ( + !usage || + typeof usage.input !== "number" || + typeof usage.output !== "number" || + typeof usage.cacheRead !== "number" || + typeof usage.cacheWrite !== "number" || + typeof usage.cost?.total !== "number" + ) { + continue; + } + cachedTokens += usage.cacheRead; + uncachedTokens += usage.input + usage.cacheWrite; + totalTokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; + costTotal += usage.cost.total; + } + return { + messageCount, + cachedTokens, + uncachedTokens, + totalTokens, + costTotal, + }; + } + + async getPathToRootOrCompaction(leafId: string | null): Promise { if (leafId === null) return []; const path: SessionTreeEntry[] = []; + let stopAtEntryId: string | null = null; let current = this.byId.get(leafId); if (!current) throw new SessionError("not_found", `Entry ${leafId} not found`); while (current) { path.unshift(current); + if (stopAtEntryId !== null && current.id === stopAtEntryId) break; + if (current.type === "compaction") { + if (current.retainedTail) break; + stopAtEntryId = current.firstKeptEntryId ?? null; + } if (!current.parentId) break; const parent = this.byId.get(current.parentId); if (!parent) throw new SessionError("invalid_session", `Entry ${current.parentId} not found`); @@ -127,7 +181,9 @@ export class InMemorySessionStorage { - return [...this.entries]; + async getEntries(options?: SessionEntryCursorOptions): Promise { + const start = options?.afterEntrySeq ?? 0; + const end = options?.limit === undefined ? undefined : start + options.limit; + return this.entries.slice(start, end); } } diff --git a/packages/agent/src/harness/session/repo-utils.ts b/packages/agent/src/harness/session/repo-utils.ts index a25b62def7b..fd6ee4f2a85 100644 --- a/packages/agent/src/harness/session/repo-utils.ts +++ b/packages/agent/src/harness/session/repo-utils.ts @@ -1,3 +1,4 @@ +import { uuidv7 } from "@earendil-works/pi-ai"; import { type FileError, type Result, @@ -7,7 +8,6 @@ import { type SessionTreeEntry, } from "../types.ts"; import { Session } from "./session.ts"; -import { uuidv7 } from "./uuid.ts"; export function createSessionId(): string { return uuidv7(); @@ -47,5 +47,5 @@ export async function getEntriesToFork( } effectiveLeafId = target.parentId; } - return storage.getPathToRoot(effectiveLeafId); + return storage.getPathToRootOrCompaction(effectiveLeafId); } diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index f5224c92249..13007bc651e 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -1,4 +1,4 @@ -import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { ImageContent, TextContent, Usage } from "@earendil-works/pi-ai"; import type { AgentMessage } from "../../types.ts"; import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts"; import type { @@ -11,8 +11,10 @@ import type { MessageEntry, ModelChangeEntry, SessionContext, + SessionEntryCursorOptions, SessionInfoEntry, SessionMetadata, + SessionStats, SessionStorage, SessionTreeEntry, ThinkingLevelChangeEntry, @@ -67,11 +69,19 @@ export function defaultContextEntryTransform(pathEntries: readonly SessionTreeEn const entries: SessionTreeEntry[] = [compaction]; const compactionIdx = pathEntries.findIndex((entry) => entry.type === "compaction" && entry.id === compaction.id); - let foundFirstKept = false; - for (let i = 0; i < compactionIdx; i++) { - const entry = pathEntries[i]!; - if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true; - if (foundFirstKept) entries.push(entry); + if (compaction.retainedTail) { + for (let i = compactionIdx + 1; i < pathEntries.length; i++) { + entries.push(pathEntries[i]!); + } + return entries; + } + if (compaction.firstKeptEntryId) { + let foundFirstKept = false; + for (let i = 0; i < compactionIdx; i++) { + const entry = pathEntries[i]!; + if (entry.id === compaction.firstKeptEntryId) foundFirstKept = true; + if (foundFirstKept) entries.push(entry); + } } for (let i = compactionIdx + 1; i < pathEntries.length; i++) { entries.push(pathEntries[i]!); @@ -111,7 +121,10 @@ export function sessionEntryToContextMessages( ]; } if (entry.type === "compaction") { - return [createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp)]; + return [ + createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp), + ...(entry.retainedTail ?? []), + ]; } if (entry.type === "branch_summary" && entry.summary) { return [createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)]; @@ -159,13 +172,13 @@ export class Session { return this.storage.getEntry(id); } - getEntries(): Promise { - return this.storage.getEntries(); + getEntries(options?: SessionEntryCursorOptions): Promise { + return this.storage.getEntries(options); } async getBranch(fromId?: string): Promise { const leafId = fromId ?? (await this.storage.getLeafId()); - return this.storage.getPathToRoot(leafId); + return this.storage.getPathToRootOrCompaction(leafId); } async buildContextEntries(options: SessionContextBuildOptions = {}): Promise { @@ -190,9 +203,12 @@ export class Session { return this.storage.getLabel(id); } + getSessionStats(): Promise { + return this.storage.getSessionStats(); + } + async getSessionName(): Promise { - const entries = await this.storage.findEntries("session_info"); - return entries[entries.length - 1]?.name?.trim() || undefined; + return this.storage.getSessionName(); } private async appendTypedEntry(entry: TEntry): Promise { @@ -243,10 +259,12 @@ export class Session { async appendCompaction( summary: string, - firstKeptEntryId: string, + firstKeptEntryId: string | undefined, tokensBefore: number, details?: T, fromHook?: boolean, + usage?: Usage, + retainedTail?: AgentMessage[], ): Promise { return this.appendTypedEntry({ type: "compaction", @@ -256,7 +274,9 @@ export class Session { summary, firstKeptEntryId, tokensBefore, + retainedTail, details, + usage, fromHook, } satisfies CompactionEntry); } @@ -317,7 +337,7 @@ export class Session { async moveTo( entryId: string | null, - summary?: { summary: string; details?: unknown; fromHook?: boolean }, + summary?: { summary: string; details?: unknown; usage?: Usage; fromHook?: boolean }, ): Promise { if (entryId !== null && !(await this.storage.getEntry(entryId))) { throw new SessionError("not_found", `Entry ${entryId} not found`); @@ -332,6 +352,7 @@ export class Session { fromId: entryId ?? "root", summary: summary.summary, details: summary.details, + usage: summary.usage, fromHook: summary.fromHook, } satisfies BranchSummaryEntry); } diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index 3f96555047f..4a2f31d9b8c 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -1,4 +1,12 @@ -import type { ImageContent, Model, Models, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai"; +import type { + ImageContent, + Model, + Models, + SimpleStreamOptions, + TextContent, + Transport, + Usage, +} from "@earendil-works/pi-ai"; import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts"; import type { Session } from "./session/session.ts"; @@ -362,9 +370,11 @@ export interface ActiveToolsChangeEntry extends SessionTreeEntryBase { export interface CompactionEntry extends SessionTreeEntryBase { type: "compaction"; summary: string; - firstKeptEntryId: string; + firstKeptEntryId?: string; tokensBefore: number; + retainedTail?: AgentMessage[]; details?: T; + usage?: Usage; fromHook?: boolean; } @@ -373,6 +383,7 @@ export interface BranchSummaryEntry extends SessionTreeEntryBase { fromId: string; summary: string; details?: T; + usage?: Usage; fromHook?: boolean; } @@ -426,6 +437,14 @@ export interface SessionContext { activeToolNames: string[] | null; } +export interface SessionStats { + messageCount: number; + cachedTokens: number; + uncachedTokens: number; + totalTokens: number; + costTotal: number; +} + export interface SessionMetadata { id: string; createdAt: string; @@ -438,6 +457,11 @@ export interface JsonlSessionMetadata extends SessionMetadata { metadata?: Record; } +export interface SessionEntryCursorOptions { + afterEntrySeq?: number; + limit?: number; +} + export interface SessionStorage { getMetadata(): Promise; getLeafId(): Promise; @@ -450,8 +474,10 @@ export interface SessionStorage>>; getLabel(id: string): Promise; - getPathToRoot(leafId: string | null): Promise; - getEntries(): Promise; + getSessionName(): Promise; + getSessionStats(): Promise; + getPathToRootOrCompaction(leafId: string | null): Promise; + getEntries(options?: SessionEntryCursorOptions): Promise; } export type { Session } from "./session/session.ts"; @@ -572,6 +598,7 @@ export interface ToolResultEvent { content: Array; details: unknown; isError: boolean; + usage?: Usage; } export interface SessionBeforeCompactEvent { @@ -687,6 +714,7 @@ export interface ToolResultPatch { content?: Array; details?: unknown; isError?: boolean; + usage?: Usage; terminate?: boolean; } @@ -697,7 +725,12 @@ export interface SessionBeforeCompactResult { export interface SessionBeforeTreeResult { cancel?: boolean; - summary?: { summary: string; details?: unknown }; + summary?: { + summary: string; + details?: unknown; + /** Usage from the LLM call that generated this summary, if available. */ + usage?: Usage; + }; customInstructions?: string; replaceInstructions?: boolean; label?: string; @@ -736,8 +769,11 @@ export interface AbortResult { export interface CompactResult { summary: string; - firstKeptEntryId: string; + firstKeptEntryId?: string; tokensBefore: number; + /** Usage from the LLM call(s) that generated this summary, if available. */ + usage?: Usage; + retainedTail?: AgentMessage[]; details?: unknown; } @@ -757,6 +793,7 @@ export interface CompactionPreparation { firstKeptEntryId: string; messagesToSummarize: AgentMessage[]; turnPrefixMessages: AgentMessage[]; + retainedTail: AgentMessage[]; isSplitTurn: boolean; tokensBefore: number; previousSummary?: string; @@ -793,6 +830,7 @@ export interface GenerateBranchSummaryOptions { export interface BranchSummaryResult { summary: string; + usage?: Usage; readFiles: string[]; modifiedFiles: string[]; } diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index 89ba269aff3..b98023b04a0 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,4 +1,5 @@ // Core Agent +export { uuidv7 } from "@earendil-works/pi-ai"; export * from "./agent.ts"; // Loop functions export * from "./agent-loop.ts"; @@ -20,6 +21,7 @@ export { findCutPoint, findTurnStartIndex, generateSummary, + generateSummaryWithUsage, getLastAssistantUsage, prepareCompaction, serializeConversation, @@ -33,7 +35,6 @@ export * from "./harness/session/memory-repo.ts"; export * from "./harness/session/memory-storage.ts"; export * from "./harness/session/repo-utils.ts"; export * from "./harness/session/session.ts"; -export { uuidv7 } from "./harness/session/uuid.ts"; export * from "./harness/skills.ts"; export * from "./harness/system-prompt.ts"; // Harness diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 5f0925c9148..5d4b7a55ed2 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.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 `streamFn` option when creating an Agent that needs to go through a proxy. + * Use this as the `streamFunction` option when creating an Agent that needs to go through a proxy. * * @example * ```typescript * const agent = new Agent({ - * streamFn: (model, context, options) => + * streamFunction: (model, context, options) => * streamProxy(model, context, { * ...options, * authToken: await getAuthToken(), diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 2de50144b98..52f34891a7e 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -11,6 +11,7 @@ import type { TextContent, Tool, ToolResultMessage, + Usage, } from "@earendil-works/pi-ai"; import type { Static, TSchema } from "typebox"; @@ -69,15 +70,18 @@ export interface BeforeToolCallResult { * - `content`: if provided, replaces the tool result content array in full * - `details`: if provided, replaces the tool result details value in full * - `isError`: if provided, replaces the tool result error flag + * - `usage`: if provided, replaces the tool result usage * - `terminate`: if provided, replaces the early-termination hint * * Omitted fields keep the original executed tool result values. - * There is no deep merge for `content` or `details`. + * There is no deep merge for `content`, `details`, or `usage`. */ export interface AfterToolCallResult { content?: (TextContent | ImageContent)[]; details?: unknown; isError?: boolean; + /** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */ + usage?: Usage; /** * Hint that the agent should stop after the current tool batch. * Early termination only happens when every finalized tool result in the batch sets this to true. @@ -273,6 +277,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * - `content` replaces the full content array * - `details` replaces the full details payload * - `isError` replaces the error flag + * - `usage` replaces the tool result usage * - `terminate` replaces the early-termination hint * * Any omitted fields keep their original values. No deep merge is performed. @@ -352,6 +357,8 @@ export interface AgentToolResult { content: (TextContent | ImageContent)[]; /** Arbitrary structured details for logs or UI rendering. */ details: T; + /** Usage from the final tool execution itself, if available. Not used for main LLM context accounting. */ + usage?: Usage; /** Names of tools introduced by this result and available from this transcript point onward. */ addedToolNames?: string[]; /** diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 03868047845..47380c42cfc 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -239,6 +239,23 @@ describe("agentLoop with AgentMessage", () => { it("should handle tool calls and results", async () => { const toolSchema = Type.Object({ value: Type.String() }); const executed: string[] = []; + const toolUsage = { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + const patchedToolUsage = { + input: 5, + output: 6, + cacheRead: 7, + cacheWrite: 8, + totalTokens: 26, + cost: { input: 0.5, output: 0.6, cacheRead: 0.7, cacheWrite: 0.8, total: 2.6 }, + }; + let observedToolUsage: typeof toolUsage | undefined; const tool: AgentTool = { name: "echo", label: "Echo", @@ -249,6 +266,7 @@ describe("agentLoop with AgentMessage", () => { return { content: [{ type: "text", text: `echoed: ${params.value}` }], details: { value: params.value }, + usage: toolUsage, }; }, }; @@ -264,6 +282,10 @@ describe("agentLoop with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, + afterToolCall: async ({ result }) => { + observedToolUsage = result.usage; + return { usage: patchedToolUsage }; + }, }; let callIndex = 0; @@ -305,6 +327,10 @@ describe("agentLoop with AgentMessage", () => { if (toolEnd?.type === "tool_execution_end") { expect(toolEnd.isError).toBe(false); } + expect(observedToolUsage).toEqual(toolUsage); + const messages = await stream.result(); + const toolResult = messages.find((message) => message.role === "toolResult"); + expect(toolResult?.role === "toolResult" ? toolResult.usage : undefined).toEqual(patchedToolUsage); }); it("should not execute tool calls from a length-truncated assistant message", async () => { @@ -1316,7 +1342,11 @@ describe("agentLoopContinue with AgentMessage", () => { convertToLlm: identityConverter, }; - expect(() => agentLoopContinue(context, config)).toThrow("Cannot continue: no messages in context"); + expect(() => + agentLoopContinue(context, config, undefined, () => { + throw new Error("Unexpected stream call"); + }), + ).toThrow("Cannot continue: no messages in context"); }); it("should continue from existing context without emitting user message events", async () => { diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index f677ee788c1..6cd77f11e4c 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,7 +1,7 @@ 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 } from "../src/index.ts"; +import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback, type StreamFn } from "../src/index.ts"; // Mock stream that mimics AssistantMessageEventStream class MockAssistantStream extends EventStream { @@ -59,6 +59,10 @@ function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMes }; } +const unusedStreamFunction: StreamFn = () => { + throw new Error("Unexpected stream call"); +}; + function createDeferred(): { promise: Promise; resolve: () => void; @@ -72,7 +76,7 @@ function createDeferred(): { describe("Agent", () => { it("should create an agent instance with default state", () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); expect(agent.state).toBeDefined(); expect(agent.state.systemPrompt).toBe(""); @@ -89,6 +93,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, initialState: { systemPrompt: "You are a helpful assistant.", model: customModel, @@ -102,7 +107,7 @@ describe("Agent", () => { }); it("should subscribe to events", () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); let eventCount = 0; const unsubscribe = agent.subscribe((_event) => { @@ -125,7 +130,7 @@ describe("Agent", () => { it("emits full lifecycle events for thrown run failures", async () => { const agent = new Agent({ - streamFn: () => { + streamFunction: () => { throw new Error("provider exploded"); }, }); @@ -157,7 +162,7 @@ describe("Agent", () => { it("should await async subscribers before prompt resolves", async () => { const barrier = createDeferred(); const agent = new Agent({ - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); @@ -195,7 +200,7 @@ describe("Agent", () => { it("waitForIdle should wait for async subscribers", async () => { const barrier = createDeferred(); const agent = new Agent({ - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: createAssistantMessage("ok") }); @@ -230,7 +235,7 @@ describe("Agent", () => { it("should pass the active abort signal to subscribers", async () => { let receivedSignal: AbortSignal | undefined; const agent = new Agent({ - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); @@ -293,7 +298,7 @@ describe("Agent", () => { }; const agent = new Agent({ initialState: { tools: [tool] }, - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ @@ -368,7 +373,7 @@ describe("Agent", () => { }; const agent = new Agent({ initialState: { tools: [settledTool, slowTool] }, - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ @@ -407,7 +412,7 @@ describe("Agent", () => { }); it("should update state with mutators", () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); // Test setSystemPrompt agent.state.systemPrompt = "Custom prompt"; @@ -446,7 +451,7 @@ describe("Agent", () => { }); it("should support steering message queue", async () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() }; agent.steer(message); @@ -456,7 +461,7 @@ describe("Agent", () => { }); it("should support follow-up message queue", async () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() }; agent.followUp(message); @@ -466,7 +471,7 @@ describe("Agent", () => { }); it("should handle abort controller", () => { - const agent = new Agent(); + const agent = new Agent({ streamFunction: unusedStreamFunction }); // Should not throw even if nothing is running expect(() => agent.abort()).not.toThrow(); @@ -476,7 +481,7 @@ describe("Agent", () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ // Use a stream function that responds to abort - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -515,7 +520,7 @@ describe("Agent", () => { it("should throw when continue() called while streaming", async () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -550,7 +555,7 @@ describe("Agent", () => { it("continue() should process queued follow-up messages after an assistant turn", async () => { const agent = new Agent({ - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Processed") }); @@ -589,7 +594,7 @@ describe("Agent", () => { it("continue() should keep one-at-a-time steering semantics from assistant tail", async () => { let responseCount = 0; const agent = new Agent({ - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); responseCount++; queueMicrotask(() => { @@ -647,7 +652,7 @@ describe("Agent", () => { sawAbortSignal = signal instanceof AbortSignal; return undefined; }, - streamFn: () => { + streamFunction: () => { requestCount++; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -671,11 +676,11 @@ describe("Agent", () => { expect(sawAbortSignal).toBe(true); }); - it("forwards sessionId to streamFn options", async () => { + it("forwards sessionId to streamFunction options", async () => { let receivedSessionId: string | undefined; const agent = new Agent({ sessionId: "session-abc", - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { receivedSessionId = options?.sessionId; const stream = new MockAssistantStream(); queueMicrotask(() => { diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts index 57d70585be5..484ef079638 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -7,6 +7,7 @@ import { fauxToolCall, type Model, registerFauxProvider, + streamSimple, type ToolResultMessage, type UserMessage, } from "@earendil-works/pi-ai/compat"; @@ -37,6 +38,7 @@ afterEach(() => { async function basicPrompt(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Keep your responses concise.", model, @@ -59,6 +61,7 @@ async function basicPrompt(model: Model) { async function toolExecution(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Always use the calculator tool for math.", model, @@ -98,6 +101,7 @@ async function toolExecution(model: Model) { async function abortExecution(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -125,6 +129,7 @@ async function abortExecution(model: Model) { async function stateUpdates(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -157,6 +162,7 @@ async function stateUpdates(model: Model) { async function multiTurnConversation(model: Model) { const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model, @@ -238,6 +244,7 @@ describe("Agent integration with faux provider", () => { faux.setResponses([fauxAssistantMessage([fauxThinking("step by step"), fauxText("4")])]); const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant.", model: faux.getModel(), @@ -262,6 +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, initialState: { systemPrompt: "Test", model: faux.getModel(), @@ -275,6 +283,7 @@ describe("Agent.continue() with faux provider", () => { const faux = createFauxRegistration(); const model = faux.getModel(); const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "Test", model, @@ -309,6 +318,7 @@ describe("Agent.continue() with faux provider", () => { const faux = createFauxRegistration(); faux.setResponses([fauxAssistantMessage("HELLO WORLD")]); const agent = new Agent({ + streamFunction: streamSimple, initialState: { systemPrompt: "You are a helpful assistant. Follow instructions exactly.", model: faux.getModel(), @@ -343,6 +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, initialState: { systemPrompt: "You are a helpful assistant. After getting a calculation result, state the answer clearly.", diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts index d13eca84aa8..48f18e249bd 100644 --- a/packages/agent/test/harness/agent-harness.test.ts +++ b/packages/agent/test/harness/agent-harness.test.ts @@ -5,6 +5,7 @@ import { fauxProvider, fauxToolCall, type RegisterFauxProviderOptions, + type Usage, } from "@earendil-works/pi-ai"; import { getModel } from "@earendil-works/pi-ai/compat"; import { describe, expect, it } from "vitest"; @@ -14,7 +15,7 @@ import { InMemorySessionStorage } from "../../src/harness/session/memory-storage import { Session } from "../../src/harness/session/session.ts"; import type { PromptTemplate, Skill } from "../../src/harness/types.ts"; import type { AgentMessage, AgentTool } from "../../src/types.ts"; -import { calculateTool } from "../utils/calculate.ts"; +import { calculateTool, createCalculateToolWithUsage } from "../utils/calculate.ts"; import { getCurrentTimeTool } from "../utils/get-current-time.ts"; interface AppSkill extends Skill { @@ -60,6 +61,34 @@ function getReasoning(options: unknown): unknown { return options.reasoning; } +function createUsage(input: number, output: number, cacheRead = 0, cacheWrite = 0): Usage { + return { + input, + output, + cacheRead, + cacheWrite, + totalTokens: input + output + cacheRead + cacheWrite, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function createUserMessage(text: string): AgentMessage { + return { role: "user", content: [{ type: "text", text }], timestamp: Date.now() }; +} + +function createAssistantMessage(text: string): AgentMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "faux", + provider: "faux", + model: "faux-1", + usage: createUsage(100, 50), + stopReason: "stop", + timestamp: Date.now(), + }; +} + describe("AgentHarness", () => { it("constructs directly and exposes queue modes", () => { const session = new Session(new InMemorySessionStorage()); @@ -427,14 +456,18 @@ describe("AgentHarness", () => { }), ]); const session = new Session(new InMemorySessionStorage()); + const toolUsage = createUsage(1, 2, 3, 4); + const patchedToolUsage = createUsage(5, 6, 7, 8); + const calculateToolWithUsage = createCalculateToolWithUsage(toolUsage); const harness = new AgentHarness({ models, env: new NodeExecutionEnv({ cwd: process.cwd() }), session, model: registration.getModel(), - tools: [calculateTool], + tools: [calculateToolWithUsage], }); const seenToolCalls: Array<{ id: string; name: string; expression: unknown }> = []; + let seenToolUsage: Usage | undefined; harness.on("tool_call", (event) => { seenToolCalls.push({ id: event.toolCallId, name: event.toolName, expression: event.input.expression }); return undefined; @@ -442,9 +475,11 @@ describe("AgentHarness", () => { harness.on("tool_result", (event) => { expect(event.toolCallId).toBe("call-1"); expect(event.toolName).toBe("calculate"); + seenToolUsage = event.usage; return { content: [{ type: "text", text: "patched result" }], details: { patched: true }, + usage: patchedToolUsage, terminate: true, }; }); @@ -455,14 +490,107 @@ describe("AgentHarness", () => { (entry) => entry.type === "message" && entry.message.role === "toolResult", ); expect(seenToolCalls).toEqual([{ id: "call-1", name: "calculate", expression: "2 + 2" }]); + expect(seenToolUsage).toEqual(toolUsage); expect(toolResult).toMatchObject({ type: "message", message: { role: "toolResult", content: [{ type: "text", text: "patched result" }], details: { patched: true }, + usage: patchedToolUsage, + }, + }); + }); + + it("persists generated compaction usage", async () => { + const registration = newFaux(); + registration.setResponses([fauxAssistantMessage("## Goal\nTest summary")]); + const session = new Session(new InMemorySessionStorage()); + await session.appendMessage(createUserMessage("one")); + await session.appendMessage(createAssistantMessage("two")); + const harness = new AgentHarness({ + models, + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), + }); + + const result = await harness.compact(); + const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction"); + + expect(result.usage?.totalTokens).toBeGreaterThan(0); + expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(result.usage); + }); + + it("persists hook-provided compaction usage", async () => { + const registration = newFaux(); + const usage = createUsage(5, 6, 7, 8); + const session = new Session(new InMemorySessionStorage()); + await session.appendMessage(createUserMessage("one")); + await session.appendMessage(createAssistantMessage("two")); + const harness = new AgentHarness({ + models, + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), + }); + harness.on("session_before_compact", (event) => ({ + compaction: { + summary: "hook summary", + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + usage, }, + })); + + const result = await harness.compact(); + const compaction = (await session.getEntries()).find((entry) => entry.type === "compaction"); + + expect(result.usage).toEqual(usage); + expect(compaction?.type === "compaction" ? compaction.usage : undefined).toEqual(usage); + }); + + it("persists generated branch summary usage", async () => { + const registration = newFaux(); + registration.setResponses([fauxAssistantMessage("## Goal\nBranch 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, + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), }); + + const result = await harness.navigateTree(targetId, { summarize: true }); + + expect(result.summaryEntry?.usage?.totalTokens).toBeGreaterThan(0); + }); + + it("persists hook-provided branch summary usage", async () => { + const registration = newFaux(); + const usage = createUsage(13, 14, 15, 16); + 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, + env: new NodeExecutionEnv({ cwd: process.cwd() }), + session, + model: registration.getModel(), + }); + harness.on("session_before_tree", () => ({ + summary: { summary: "hook branch summary", usage }, + })); + + const result = await harness.navigateTree(targetId, { summarize: true }); + + expect(result.summaryEntry?.usage).toEqual(usage); }); it("preserves app tool types for getters and update events", async () => { diff --git a/packages/agent/test/harness/compaction.test.ts b/packages/agent/test/harness/compaction.test.ts index 8a228eeb868..e55be45a5a1 100644 --- a/packages/agent/test/harness/compaction.test.ts +++ b/packages/agent/test/harness/compaction.test.ts @@ -6,6 +6,7 @@ import { fauxProvider, type Message, type Model, + type Models, type Usage, } from "@earendil-works/pi-ai"; import { beforeEach, describe, expect, it } from "vitest"; @@ -19,6 +20,7 @@ import { findCutPoint, findTurnStartIndex, generateSummary, + generateSummaryWithUsage, getLastAssistantUsage, prepareCompaction, serializeConversation, @@ -89,6 +91,7 @@ function createCompactionEntry( summary: string, firstKeptEntryId: string, parentId: string | null = null, + retainedTail?: AgentMessage[], ): CompactionEntry { return { type: "compaction", @@ -98,6 +101,7 @@ function createCompactionEntry( summary, firstKeptEntryId, tokensBefore: 1234, + retainedTail, }; } @@ -142,6 +146,17 @@ function createFauxModel(reasoning: boolean, maxTokens = 8192): { faux: FauxProv return { faux, model: faux.getModel() }; } +function createModelsWithSimpleResponses(responses: AssistantMessage[]): Models { + const remaining = [...responses]; + const stub = Object.create(models) as Models; + stub.completeSimple = async () => { + const response = remaining.shift(); + if (!response) throw new Error("No faux completeSimple response queued"); + return response; + }; + return stub; +} + describe("harness compaction", () => { beforeEach(() => { nextId = 0; @@ -330,12 +345,38 @@ describe("harness compaction", () => { const a1 = createMessageEntry(createAssistantMessage("a"), u1.id); const u2 = createMessageEntry(createUserMessage("2"), a1.id); const a2 = createMessageEntry(createAssistantMessage("b"), u2.id); - const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id); + const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id, [ + createUserMessage("2"), + createAssistantMessage("b"), + ]); const u3 = createMessageEntry(createUserMessage("3"), compaction.id); const a3 = createMessageEntry(createAssistantMessage("c"), u3.id); const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3, a3]); expect(loaded.messages).toHaveLength(5); expect(loaded.messages[0]?.role).toBe("compactionSummary"); + expect(loaded.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + "user", + "assistant", + ]); + }); + + it("falls back to firstKeptEntryId when a compaction has no retained tail", () => { + const u1 = createMessageEntry(createUserMessage("1")); + const a1 = createMessageEntry(createAssistantMessage("a"), u1.id); + const u2 = createMessageEntry(createUserMessage("2"), a1.id); + const a2 = createMessageEntry(createAssistantMessage("b"), u2.id); + const compaction = createCompactionEntry("Summary of 1,a,2,b", u2.id, a2.id); + const u3 = createMessageEntry(createUserMessage("3"), compaction.id); + const loaded = buildSessionContext([u1, a1, u2, a2, compaction, u3]); + expect(loaded.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + "user", + ]); }); it("tracks model and thinking level changes in built context", () => { @@ -361,6 +402,7 @@ describe("harness compaction", () => { expect(preparation).toBeDefined(); expect(preparation?.previousSummary).toBe("First summary"); expect(preparation?.firstKeptEntryId).toBeTruthy(); + expect(preparation?.retainedTail.length).toBeGreaterThan(0); expect(preparation?.tokensBefore).toBe(estimateContextTokens(buildSessionContext(pathEntries).messages).tokens); }); @@ -498,14 +540,27 @@ describe("harness compaction", () => { ]); const summary = getOrThrow( - await generateSummary(messages, models, model, 2000, undefined, "focus", "old summary"), + await generateSummaryWithUsage(messages, models, model, 2000, undefined, "focus", "old summary"), ); - expect(summary).toContain("Test summary"); + expect(summary.text).toContain("Test summary"); + expect(summary.usage.input).toBeGreaterThan(0); + expect(summary.usage.output).toBeGreaterThan(0); + expect(summary.usage.totalTokens).toBe( + summary.usage.input + summary.usage.output + summary.usage.cacheRead + summary.usage.cacheWrite, + ); expect(promptText).toContain("\nold summary\n"); expect(promptText).toContain("Additional focus: focus"); }); + it("preserves the string result from generateSummary", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + const { faux, model } = createFauxModel(false); + faux.setResponses([fauxAssistantMessage("## Goal\nTest summary")]); + + expect(getOrThrow(await generateSummary(messages, models, model, 2000))).toBe("## Goal\nTest summary"); + }); + it("returns error results for failed or aborted summary generations", async () => { const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; const { faux: errorFaux, model: errorModel } = createFauxModel(false); @@ -540,6 +595,7 @@ describe("harness compaction", () => { firstKeptEntryId: "entry-keep", messagesToSummarize: messages, turnPrefixMessages: messages, + retainedTail: messages, isSplitTurn: true, tokensBefore: 600000, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, @@ -557,6 +613,7 @@ describe("harness compaction", () => { firstKeptEntryId: "entry-keep", messagesToSummarize: messages, turnPrefixMessages: [], + retainedTail: messages, isSplitTurn: false, tokensBefore: 100, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, @@ -578,6 +635,31 @@ describe("harness compaction", () => { expect(invalidResult).toMatchObject({ ok: false, error: { code: "invalid_session" } }); }); + it("combines usage for split-turn compaction summaries", async () => { + const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; + const { model } = createFauxModel(false); + const historyUsage = createMockUsage(1, 2, 3, 4); + const turnPrefixUsage = createMockUsage(5, 6, 7, 8); + const usageModels = createModelsWithSimpleResponses([ + { ...fauxAssistantMessage("history summary"), usage: historyUsage }, + { ...fauxAssistantMessage("turn prefix summary"), usage: turnPrefixUsage }, + ]); + const preparation: CompactionPreparation = { + firstKeptEntryId: "entry-keep", + messagesToSummarize: messages, + turnPrefixMessages: messages, + isSplitTurn: true, + tokensBefore: 100, + retainedTail: messages, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: { enabled: true, reserveTokens: 2000, keepRecentTokens: 20 }, + }; + + const result = getOrThrow(await compact(preparation, usageModels, model)); + + expect(result.usage).toEqual(createMockUsage(6, 8, 10, 12)); + }); + it("passes reasoning through turn-prefix summaries when enabled", async () => { const messages: AgentMessage[] = [createUserMessage("Summarize this.")]; const seenOptions: Array | undefined> = []; @@ -592,6 +674,7 @@ describe("harness compaction", () => { firstKeptEntryId: "entry-keep", messagesToSummarize: [], turnPrefixMessages: messages, + retainedTail: messages, isSplitTurn: true, tokensBefore: 100, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, @@ -609,6 +692,7 @@ describe("harness compaction", () => { firstKeptEntryId: "entry-keep", messagesToSummarize: [], turnPrefixMessages: messages, + retainedTail: messages, isSplitTurn: true, tokensBefore: 100, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, @@ -646,6 +730,8 @@ describe("harness compaction", () => { const result = getOrThrow(await compact(preparation!, models, model)); expect(result.summary.length).toBeGreaterThan(0); expect(result.firstKeptEntryId).toBeTruthy(); + expect(result.usage?.totalTokens).toBeGreaterThan(0); + expect(result.retainedTail?.length).toBeGreaterThan(0); expect(result.details).toBeDefined(); }); }); diff --git a/packages/agent/test/harness/session.test.ts b/packages/agent/test/harness/session.test.ts index c009123d6ff..70adbb386cb 100644 --- a/packages/agent/test/harness/session.test.ts +++ b/packages/agent/test/harness/session.test.ts @@ -68,11 +68,20 @@ async function runSessionSuite( await session.appendMessage(createAssistantMessage("two")); const user2 = await session.appendMessage(createUserMessage("three")); await session.appendMessage(createAssistantMessage("four")); - await session.appendCompaction("summary", user2, 1234); + await session.appendCompaction("summary", user2, 1234, undefined, undefined, undefined, [ + createUserMessage("three"), + createAssistantMessage("four"), + ]); await session.appendMessage(createUserMessage("five")); const context = await session.buildContext(); expect(context.messages[0]?.role).toBe("compactionSummary"); expect(context.messages).toHaveLength(4); + expect(context.messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + "assistant", + "user", + ]); }); it("supports moving with branch summary entries in context", async () => { @@ -86,6 +95,49 @@ async function runSessionSuite( expect(context.messages[1]?.role).toBe("branchSummary"); }); + it("persists compaction usage", async () => { + const session = new Session(await createStorage()); + const firstKeptEntryId = await session.appendMessage(createUserMessage("one")); + const usage = { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + + const compactionId = await session.appendCompaction( + "summary", + firstKeptEntryId, + 1234, + undefined, + false, + usage, + ); + + const compactionEntry = await session.getEntry(compactionId); + expect(compactionEntry?.type === "compaction" ? compactionEntry.usage : undefined).toEqual(usage); + }); + + it("persists branch summary usage", async () => { + const session = new Session(await createStorage()); + const user1 = await session.appendMessage(createUserMessage("one")); + const usage = { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + + const summaryId = await session.moveTo(user1, { summary: "summary text", usage }); + + const summaryEntry = await session.getEntry(summaryId!); + expect(summaryEntry?.type === "branch_summary" ? summaryEntry.usage : undefined).toEqual(usage); + }); + it("supports custom message entries in context", async () => { const session = new Session(await createStorage()); await session.appendMessage(createUserMessage("one")); diff --git a/packages/agent/test/harness/sqlite-migrations.test.ts b/packages/agent/test/harness/sqlite-migrations.test.ts new file mode 100644 index 00000000000..0cf74938df9 --- /dev/null +++ b/packages/agent/test/harness/sqlite-migrations.test.ts @@ -0,0 +1,441 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + applyMigrations, + createNodeSqliteFactory, + type SqliteDatabase, + type SqliteDatabaseFactory, + type SqliteRunResult, + type SqliteSessionMetadata, + SqliteSessionRepo, + SqliteSessionStorage, + type SqliteStatement, +} from "../../../storage/sqlite-node/src/index.ts"; +import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; +import { createAssistantMessage, createUserMessage } from "./session-test-utils.ts"; + +function createTempDir(): string { + return mkdtempSync(join(tmpdir(), "pi-agent-sqlite-")); +} + +class ThrowingStatement implements SqliteStatement { + private readonly onRun: () => Promise; + + constructor(onRun: () => Promise) { + this.onRun = onRun; + } + + async run(..._params: unknown[]): Promise { + return this.onRun(); + } + + async get(..._params: unknown[]): Promise { + return undefined; + } + + async all(..._params: unknown[]): Promise { + return []; + } +} + +class CountingDatabase implements SqliteDatabase { + closeCount = 0; + private readonly statementFactory: (sql: string) => SqliteStatement; + + constructor(statementFactory: (sql: string) => SqliteStatement) { + this.statementFactory = statementFactory; + } + + async exec(_sql: string): Promise {} + + prepare(sql: string): SqliteStatement { + return this.statementFactory(sql); + } + + async transaction(fn: () => Promise): Promise { + return fn(); + } + + async close(): Promise { + this.closeCount += 1; + } +} + +describe("SQLite migrations", () => { + it("applies file-based migrations and records them", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const repo = new SqliteSessionRepo({ env, sqlite, databasePath }); + await repo.create({ cwd: root, id: "session-1" }); + + const db = await sqlite.open(databasePath); + try { + const rows = await db.prepare("SELECT id FROM migrations ORDER BY id").all<{ id: string }>(); + expect(rows.map((row) => row.id)).toEqual(["001_initial.sql"]); + const tables = await db + .prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all<{ name: string; sql: string | null }>(); + expect(tables.map((row) => row.name)).toEqual( + expect.arrayContaining([ + "migrations", + "sessions", + "session_entries", + "session_sequences", + "branch_entries", + "session_materialized", + "entry_materialized", + ]), + ); + const sessionColumns = await db.prepare("PRAGMA table_info(sessions)").all<{ name: string }>(); + expect(sessionColumns.map((column) => column.name)).toContain("active_leaf_id"); + for (const tableName of [ + "sessions", + "session_sequences", + "branch_entries", + "session_materialized", + "entry_materialized", + ]) { + const table = tables.find((row) => row.name === tableName); + expect(table?.sql).toContain("WITHOUT ROWID"); + } + } finally { + await db.close(); + } + }); + + it("persists session metadata through create, list, open, and fork", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath }); + const source = await repo.create({ + cwd: root, + id: "session-1", + metadata: { profile: "reviewer" }, + }); + const sourceMetadata = await source.getMetadata(); + expect(sourceMetadata.metadata).toEqual({ profile: "reviewer" }); + expect((await repo.list({ cwd: root })).map((listed) => listed.metadata)).toEqual([{ profile: "reviewer" }]); + expect((await (await repo.open(sourceMetadata)).getMetadata()).metadata).toEqual({ profile: "reviewer" }); + const fork = await repo.fork(sourceMetadata, { cwd: root, id: "session-2" }); + expect((await fork.getMetadata()).metadata).toEqual({ profile: "reviewer" }); + const overridden = await repo.fork(sourceMetadata, { + cwd: root, + id: "session-3", + metadata: { profile: "writer" }, + }); + expect((await overridden.getMetadata()).metadata).toEqual({ profile: "writer" }); + }); + + it("materializes active leaf id in sessions transactionally", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const repo = new SqliteSessionRepo({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + const rootId = await session.appendMessage(createUserMessage("root")); + const childId = await session.appendMessage(createAssistantMessage("child")); + await session.getStorage().setLeafId(rootId); + + const db = await sqlite.open(databasePath); + try { + const row = await db + .prepare("SELECT active_leaf_id FROM sessions WHERE id = ?") + .get<{ active_leaf_id: string | null }>("session-1"); + expect(row?.active_leaf_id).toBe(rootId); + const latestBranchRow = await db + .prepare( + "SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1", + ) + .get<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1"); + const latestSessionEntry = await db + .prepare("SELECT id, type FROM session_entries WHERE session_id = ? ORDER BY entry_seq DESC LIMIT 1") + .get<{ id: string; type: string }>("session-1"); + expect(latestSessionEntry?.type).toBe("leaf"); + expect(latestBranchRow?.entry_id).toBe(latestSessionEntry?.id); + } finally { + await db.close(); + } + + const reopened = await repo.open(await session.getMetadata()); + expect(await reopened.getLeafId()).toBe(rootId); + expect(childId).not.toBe(rootId); + }); + + it("materializes a new branch when appending from a parent with an existing child", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const repo = new SqliteSessionRepo({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + const rootId = await session.appendMessage(createUserMessage("root")); + const firstChildId = await session.appendMessage(createAssistantMessage("first child")); + await session.getStorage().setLeafId(rootId); + const secondChildId = await session.appendMessage(createAssistantMessage("second child")); + + const db = await sqlite.open(databasePath); + try { + const branchRows = await db + .prepare( + "SELECT branch_id, entry_id, entry_seq FROM branch_entries WHERE session_id = ? ORDER BY branch_id, entry_seq", + ) + .all<{ branch_id: string; entry_id: string; entry_seq: number }>("session-1"); + const branchIds = [...new Set(branchRows.map((row) => row.branch_id))]; + expect(branchIds).toHaveLength(3); + expect(branchRows.filter((row) => row.entry_id === rootId)).toHaveLength(3); + expect(branchRows.filter((row) => row.entry_id === firstChildId)).toHaveLength(1); + expect(branchRows.filter((row) => row.entry_id === secondChildId)).toHaveLength(1); + } finally { + await db.close(); + } + }); + + it("reopens using branch materialization and session summary state", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + const rootId = await session.appendMessage(createUserMessage("root")); + await session.appendMessage(createAssistantMessage("first child")); + await session.appendSessionName(" Reopened Session "); + await session.getStorage().setLeafId(rootId); + await session.appendMessage(createAssistantMessage("branched child")); + + const reopened = await repo.open(await session.getMetadata()); + expect(await reopened.getSessionName()).toBe("Reopened Session"); + expect((await reopened.buildContext()).messages.map((message) => message.role)).toEqual(["user", "assistant"]); + expect((await reopened.buildContext()).messages.at(-1)).toMatchObject({ + content: [{ type: "text", text: "branched child" }], + }); + }); + + it("pages entries by entry_seq cursor", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + await session.appendMessage(createUserMessage("one")); + await session.appendMessage(createAssistantMessage("two")); + await session.appendMessage(createUserMessage("three")); + + expect((await session.getEntries({ limit: 2 })).map((entry) => entry.type)).toEqual(["message", "message"]); + expect((await session.getEntries({ afterEntrySeq: 2, limit: 2 })).map((entry) => entry.type)).toEqual([ + "message", + "message", + ]); + }); + + it("closes the database when create fails after openDatabase succeeds", async () => { + const root = createTempDir(); + const db = new CountingDatabase((sql) => { + if (sql.startsWith("INSERT INTO sessions")) { + return new ThrowingStatement(async () => { + throw new Error("insert failed"); + }); + } + return new ThrowingStatement(async () => ({ changes: 1 })); + }); + const sqlite: SqliteDatabaseFactory = { + open: async () => db, + }; + const env = new NodeExecutionEnv({ cwd: root }); + const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); + + await expect(repo.create({ cwd: root, id: "session-1" })).rejects.toThrow("insert failed"); + expect(db.closeCount).toBe(1); + }); + + it("closes the database when open fails after openDatabase succeeds", async () => { + const root = createTempDir(); + const db = new CountingDatabase((sql) => { + if (sql.includes("FROM sessions WHERE id = ?")) { + return new ThrowingStatement(async () => ({ changes: 0 })); + } + return new ThrowingStatement(async () => ({ changes: 1 })); + }); + const sqlite: SqliteDatabaseFactory = { + open: async () => db, + }; + const env = new NodeExecutionEnv({ cwd: root }); + const repo = new SqliteSessionRepo({ env, sqlite, databasePath: join(root, "sessions.sqlite") }); + const metadata: SqliteSessionMetadata = { + id: "missing", + createdAt: new Date().toISOString(), + cwd: root, + path: join(root, "sessions.sqlite"), + }; + writeFileSync(metadata.path, ""); + + await expect(repo.open(metadata)).rejects.toThrow("Session not found: missing"); + expect(db.closeCount).toBe(1); + }); + + it("closes the source storage after fork reads its entries", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const repo = new SqliteSessionRepo({ env, sqlite: createNodeSqliteFactory(), databasePath }); + let cleanupCount = 0; + const sourceStorage = { + async getEntries() { + return []; + }, + async getPathToRootOrCompaction() { + return []; + }, + async cleanup() { + cleanupCount += 1; + }, + } as const; + const originalOpen = repo.open.bind(repo); + repo.open = async () => + ({ + getStorage() { + return sourceStorage; + }, + }) as never; + + try { + await repo.fork( + { + id: "session-1", + createdAt: new Date().toISOString(), + cwd: root, + path: databasePath, + }, + { cwd: root, id: "session-2" }, + ); + } finally { + repo.open = originalOpen; + } + + expect(cleanupCount).toBe(1); + }); + + it("restores in-memory state when appendEntry fails after mutating caches", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const sqlite = createNodeSqliteFactory(); + const db = await sqlite.open(databasePath); + await applyMigrations(db); + const storage = await SqliteSessionStorage.create(db, databasePath, { + cwd: root, + sessionId: "session-1", + }); + const originalPrepare = db.prepare.bind(db); + db.prepare = (sql: string) => { + if (sql.startsWith("UPDATE sessions SET active_leaf_id = ?")) { + return new ThrowingStatement(async () => { + throw new Error("active leaf update failed"); + }); + } + return originalPrepare(sql); + }; + + await expect( + storage.appendEntry({ + type: "message", + id: "root", + parentId: null, + timestamp: new Date().toISOString(), + message: createUserMessage("root"), + }), + ).rejects.toMatchObject({ code: "storage" }); + expect(await storage.getLeafId()).toBeNull(); + expect(await storage.getEntry("root")).toBeUndefined(); + expect(await storage.getEntries()).toEqual([]); + await db.close(); + }); + + it("materializes session summary fields transactionally", async () => { + const root = createTempDir(); + const databasePath = join(root, "sessions.sqlite"); + const env = new NodeExecutionEnv({ cwd: root }); + const sqlite = createNodeSqliteFactory(); + const repo = new SqliteSessionRepo({ env, sqlite, databasePath }); + const session = await repo.create({ cwd: root, id: "session-1" }); + const userId = await session.appendMessage(createUserMessage("one")); + await session.appendThinkingLevelChange("high"); + await session.appendModelChange("anthropic", "claude-sonnet-4-5"); + const assistant = { + ...createAssistantMessage("two"), + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + input: 100, + output: 25, + cacheRead: 40, + cacheWrite: 10, + totalTokens: 175, + cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 }, + }, + }; + await session.appendMessage(assistant); + await session.appendCompaction("summary", userId, 200, undefined, false, { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, + }); + await session.moveTo(userId, { + summary: "branch summary", + usage: { + input: 5, + output: 6, + cacheRead: 7, + cacheWrite: 8, + totalTokens: 26, + cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, + }, + }); + await session.appendSessionName(" My Session "); + await session.appendLabel(userId, "checkpoint"); + + const db = await sqlite.open(databasePath); + try { + const row = await db.prepare("SELECT session_id, payload FROM session_materialized WHERE session_id = ?").get<{ + session_id: string; + payload: string; + }>("session-1"); + expect(row).toBeDefined(); + expect(row?.session_id).toBe("session-1"); + expect(JSON.parse(row?.payload ?? "null")).toMatchObject({ + name: "My Session", + messageCount: 2, + cachedTokens: 50, + uncachedTokens: 128, + totalTokens: 211, + costTotal: 0.73, + currentModel: { provider: "anthropic", modelId: "claude-sonnet-4-5" }, + currentThinkingLevel: "high", + }); + const entryRows = await db + .prepare( + "SELECT session_id, entry_seq, type, payload FROM entry_materialized WHERE session_id = ? ORDER BY entry_seq, type", + ) + .all<{ + session_id: string; + entry_seq: number; + type: string; + payload: string; + }>("session-1"); + expect( + entryRows.some((entryRow) => entryRow.type === "label" && JSON.parse(entryRow.payload).targetId === userId), + ).toBe(true); + expect(entryRows.some((entryRow) => entryRow.type === "thinking")).toBe(false); + expect(entryRows.some((entryRow) => entryRow.type === "model")).toBe(false); + } finally { + await db.close(); + } + }); +}); diff --git a/packages/agent/test/harness/sqlite-node.test.ts b/packages/agent/test/harness/sqlite-node.test.ts new file mode 100644 index 00000000000..a94d3785873 --- /dev/null +++ b/packages/agent/test/harness/sqlite-node.test.ts @@ -0,0 +1,21 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createNodeSqliteFactory } from "../../../storage/sqlite-node/src/index.ts"; +import { createTempDir } from "./session-test-utils.ts"; + +describe("sqlite-node adapter", () => { + it("supports node:sqlite-style named parameters", async () => { + const root = createTempDir(); + const databasePath = join(root, "adapter.sqlite"); + const sqlite = createNodeSqliteFactory(); + const db = await sqlite.open(databasePath); + try { + await db.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, text TEXT NOT NULL)"); + await db.prepare("INSERT INTO items (id, text) VALUES ($id, $text)").run({ $id: 1, $text: "hello" }); + const row = await db.prepare("SELECT text FROM items WHERE id = $id").get<{ text: string }>({ $id: 1 }); + expect(row).toEqual({ text: "hello" }); + } finally { + await db.close(); + } + }); +}); diff --git a/packages/agent/test/harness/storage.test.ts b/packages/agent/test/harness/storage.test.ts index 07f07df26f6..50f4fc59ad6 100644 --- a/packages/agent/test/harness/storage.test.ts +++ b/packages/agent/test/harness/storage.test.ts @@ -4,7 +4,13 @@ import { describe, expect, it } from "vitest"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { JsonlSessionStorage, loadJsonlSessionMetadata } from "../../src/harness/session/jsonl-storage.ts"; import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts"; -import { type MessageEntry, ok, type SessionMetadata } from "../../src/harness/types.ts"; +import { + type BranchSummaryEntry, + type CompactionEntry, + type MessageEntry, + ok, + type SessionMetadata, +} from "../../src/harness/types.ts"; import { createAssistantMessage, createTempDir, createUserMessage } from "./session-test-utils.ts"; describe("InMemorySessionStorage", () => { @@ -80,7 +86,74 @@ describe("InMemorySessionStorage", () => { expect(await storage.getLabel("entry-1")).toBeUndefined(); }); - it("walks paths to root", async () => { + it("includes summary-entry usage in session stats", async () => { + const assistant: MessageEntry = { + type: "message", + id: "assistant", + parentId: null, + timestamp: "2026-01-01T00:00:00.000Z", + message: { + role: "assistant", + content: [{ type: "text", text: "reply" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }, + stopReason: "stop", + timestamp: 0, + }, + }; + const compaction: CompactionEntry = { + type: "compaction", + id: "compaction", + parentId: "assistant", + timestamp: "2026-01-01T00:00:01.000Z", + summary: "summary", + firstKeptEntryId: "assistant", + tokensBefore: 1234, + usage: { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, + }, + }; + const branchSummary: BranchSummaryEntry = { + type: "branch_summary", + id: "branch-summary", + parentId: "compaction", + timestamp: "2026-01-01T00:00:02.000Z", + fromId: "assistant", + summary: "branch", + usage: { + input: 5, + output: 6, + cacheRead: 7, + cacheWrite: 8, + totalTokens: 26, + cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, + }, + }; + const storage = new InMemorySessionStorage({ entries: [assistant, compaction, branchSummary] }); + expect(await storage.getSessionStats()).toEqual({ + messageCount: 1, + cachedTokens: 40, + uncachedTokens: 68, + totalTokens: 136, + costTotal: 1.36, + }); + }); + + it("walks paths to root or retained-tail compaction", async () => { const root: MessageEntry = { type: "message", id: "root", @@ -94,9 +167,29 @@ describe("InMemorySessionStorage", () => { parentId: "root", message: createAssistantMessage("child"), }; - const storage = new InMemorySessionStorage({ entries: [root, child] }); - expect((await storage.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]); - expect(await storage.getPathToRoot(null)).toEqual([]); + const compaction: CompactionEntry = { + type: "compaction", + id: "compaction", + parentId: "child", + timestamp: "2026-01-01T00:00:01.000Z", + summary: "summary", + firstKeptEntryId: "child", + tokensBefore: 1234, + retainedTail: [createAssistantMessage("child")], + }; + const afterCompaction: MessageEntry = { + ...root, + id: "after-compaction", + parentId: "compaction", + message: createUserMessage("after"), + }; + const storage = new InMemorySessionStorage({ entries: [root, child, compaction, afterCompaction] }); + expect((await storage.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]); + expect((await storage.getPathToRootOrCompaction("after-compaction")).map((entry) => entry.id)).toEqual([ + "compaction", + "after-compaction", + ]); + expect(await storage.getPathToRootOrCompaction(null)).toEqual([]); }); }); @@ -255,7 +348,7 @@ describe("JsonlSessionStorage", () => { const reloaded = await JsonlSessionStorage.open(env, filePath); expect(await reloaded.getLeafId()).toBe("root"); expect((await reloaded.getEntries()).at(-1)).toMatchObject({ type: "leaf", targetId: "root" }); - expect((await loaded.getPathToRoot("child")).map((entry) => entry.id)).toEqual(["root", "child"]); + expect((await loaded.getPathToRootOrCompaction("child")).map((entry) => entry.id)).toEqual(["root", "child"]); }); it("finds entries by type", async () => { @@ -309,6 +402,76 @@ describe("JsonlSessionStorage", () => { expect(await loaded.getLabel("entry-1")).toBeUndefined(); }); + it("includes summary-entry usage in session stats", async () => { + const dir = createTempDir(); + const env = new NodeExecutionEnv({ cwd: dir }); + const filePath = join(dir, "session.jsonl"); + const storage = await JsonlSessionStorage.create(env, filePath, { cwd: dir, sessionId: "session-1" }); + await storage.appendEntry({ + type: "message", + id: "assistant", + parentId: null, + timestamp: "2026-01-01T00:00:00.000Z", + message: { + role: "assistant", + content: [{ type: "text", text: "reply" }], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + usage: { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }, + stopReason: "stop", + timestamp: 0, + }, + }); + await storage.appendEntry({ + type: "compaction", + id: "compaction", + parentId: "assistant", + timestamp: "2026-01-01T00:00:01.000Z", + summary: "summary", + firstKeptEntryId: "assistant", + tokensBefore: 1234, + usage: { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 }, + }, + }); + await storage.appendEntry({ + type: "branch_summary", + id: "branch-summary", + parentId: "compaction", + timestamp: "2026-01-01T00:00:02.000Z", + fromId: "assistant", + summary: "branch", + usage: { + input: 5, + output: 6, + cacheRead: 7, + cacheWrite: 8, + totalTokens: 26, + cost: { input: 0.05, output: 0.06, cacheRead: 0.07, cacheWrite: 0.08, total: 0.26 }, + }, + }); + expect(await storage.getSessionStats()).toEqual({ + messageCount: 1, + cachedTokens: 40, + uncachedTokens: 68, + totalTokens: 136, + costTotal: 1.36, + }); + }); + it("reads session metadata through the line-reading filesystem operation", async () => { const dir = createTempDir(); const filePath = join(dir, "session.jsonl"); diff --git a/packages/agent/test/utils/calculate.ts b/packages/agent/test/utils/calculate.ts index 6158efbde73..0a4fefc58c3 100644 --- a/packages/agent/test/utils/calculate.ts +++ b/packages/agent/test/utils/calculate.ts @@ -1,3 +1,4 @@ +import type { Usage } from "@earendil-works/pi-ai"; import { type Static, Type } from "typebox"; import type { AgentTool, AgentToolResult } from "../../src/types.ts"; @@ -30,3 +31,10 @@ export const calculateTool: AgentTool = { return calculate(args.expression); }, }; + +export function createCalculateToolWithUsage(usage: Usage): AgentTool { + return { + ...calculateTool, + execute: async (_toolCallId: string, args: CalculateParams) => ({ ...calculate(args.expression), usage }), + }; +} diff --git a/packages/agent/vitest.harness.config.ts b/packages/agent/vitest.harness.config.ts index 91c0d471b3b..8045bb1a628 100644 --- a/packages/agent/vitest.harness.config.ts +++ b/packages/agent/vitest.harness.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"; const aiSrcIndex = fileURLToPath(new URL("../ai/src/index.ts", import.meta.url)); const aiSrcCompat = fileURLToPath(new URL("../ai/src/compat.ts", import.meta.url)); +const agentSrcIndex = fileURLToPath(new URL("../agent/src/index.ts", import.meta.url)); export default defineConfig({ test: { @@ -21,6 +22,7 @@ export default defineConfig({ }, resolve: { alias: [ + { find: /^@earendil-works\/pi-agent-core$/, replacement: agentSrcIndex }, { find: /^@earendil-works\/pi-ai$/, replacement: aiSrcIndex }, { find: /^@earendil-works\/pi-ai\/compat$/, replacement: aiSrcCompat }, ], diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 694c828b095..c559aed0551 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,11 +2,32 @@ ## [Unreleased] +## [0.81.0] - 2026-07-21 + +### Added + +- Added Qwen Token Plan and Qwen Token Plan China as built-in providers with regional endpoints, API-key authentication, and generated model catalogs ([#6858](https://github.com/earendil-works/pi/pull/6858) by [@QuintinShaw](https://github.com/QuintinShaw)). +- 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)). + +### Changed + +- Changed generated model catalogs to keep TypeScript model shapes separate from ignored JSON model values, reducing generated source churn ([#6765](https://github.com/earendil-works/pi/pull/6765) by [@mitsuhiko](https://github.com/mitsuhiko)). +- Changed model generation to validate ignored provider data before compilation; `npm run build` refreshes model data as before, while `npm run build:offline` reuses existing data without network access. + ### Fixed +- Fixed stored API-key credentials to apply their provider-scoped `env` values during auth resolution, including Amazon Bedrock profiles ([#6864](https://github.com/earendil-works/pi/pull/6864) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Fixed OpenAI-compatible cross-provider replay to preserve unique tool call IDs when multiple calls share a provider call ID ([#6854](https://github.com/earendil-works/pi/pull/6854) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Fixed Kimi K3 to expose its supported low, high, and max thinking levels, and normalized the `k2p7` alias to the canonical `kimi-for-coding` model. +- Fixed the OpenCode Go provider to support models routed through the OpenAI Responses API. +- Fixed the `pi-ai` executable path to match npm registry metadata, avoiding repeated consumer lockfile changes ([#6812](https://github.com/earendil-works/pi/pull/6812) by [@jmfederico](https://github.com/jmfederico)). +- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs, enabling models that reject UUIDv4 IDs ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)). - Fixed GitHub Copilot long-context pricing tiers in generated model metadata ([#6668](https://github.com/earendil-works/pi/issues/6668)). - Fixed Kimi Coding subscription models to report API-equivalent implied costs when models.dev reports zero pricing. - Fixed OpenAI Responses early stream endings to be classified as retryable provider errors ([#6727](https://github.com/earendil-works/pi/issues/6727)). +- Fixed GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6853](https://github.com/earendil-works/pi/pull/6853) by [@aadishv](https://github.com/aadishv)). ## [0.80.10] - 2026-07-16 diff --git a/packages/ai/README.md b/packages/ai/README.md index ee047aeb4be..3c791621d9c 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -434,6 +434,8 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | Hugging Face | `HF_TOKEN` | | OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` | | Kimi For Coding | `KIMI_API_KEY` | +| Qwen Token Plan | `QWEN_TOKEN_PLAN_API_KEY` | +| Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | | Xiaomi MiMo (API billing) | `XIAOMI_API_KEY` | | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | | Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | diff --git a/packages/ai/package.json b/packages/ai/package.json index 5c17294af96..8a64b01e935 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -41,7 +41,7 @@ } }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "files": [ "dist", @@ -49,10 +49,13 @@ ], "scripts": { "clean": "shx rm -rf dist", - "generate-models": "node scripts/generate-models.ts", + "generate-models": "node scripts/generate-models.ts --strict", + "hydrate-model-data": "node scripts/generate-models.ts --strict --data-only", "generate-model-catalog": "node scripts/generate-models.ts --strict --json-only --json-output ../../.artifacts/model-catalog", - "generate-image-models": "node scripts/generate-image-models.ts", - "build": "npm run generate-models && tsgo -p tsconfig.build.json && shx rm -rf dist/providers/data && shx cp -r src/providers/data dist/providers/data", + "generate-image-models": "node scripts/generate-image-models.ts --strict", + "check:model-data": "node scripts/check-model-data.ts", + "build": "npm run generate-models && npm run build:offline", + "build:offline": "npm run check:model-data && tsgo -p tsconfig.build.json && shx rm -rf dist/providers/data && shx cp -r src/providers/data dist/providers/data", "test": "vitest --run", "prepublishOnly": "npm run clean && npm run build" }, diff --git a/packages/ai/scripts/check-model-data.ts b/packages/ai/scripts/check-model-data.ts new file mode 100644 index 00000000000..5c61b52c4bd --- /dev/null +++ b/packages/ai/scripts/check-model-data.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateGeneratedModelData } from "./model-data.ts"; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); + +try { + validateGeneratedModelData(packageRoot); + console.log("Generated model data is valid."); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error("\nModel data is missing or stale. Run `npm run hydrate:model-data` from the repository root."); + process.exitCode = 1; +} diff --git a/packages/ai/scripts/generate-image-models.ts b/packages/ai/scripts/generate-image-models.ts index 2744da48758..26e46e4503a 100644 --- a/packages/ai/scripts/generate-image-models.ts +++ b/packages/ai/scripts/generate-image-models.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { writeFileSync } from "fs"; -import { dirname, join } from "path"; +import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import type { ImagesModel } from "../src/types.ts"; @@ -10,6 +10,13 @@ const __dirname = dirname(__filename); const packageRoot = join(__dirname, ".."); const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +function readStrictOption(args: string[]): boolean { + for (const arg of args) { + if (arg !== "--strict") throw new Error(`Unknown argument: ${arg}`); + } + return args.includes("--strict"); +} + interface OpenRouterModelRecord { id: string; name: string; @@ -26,52 +33,73 @@ interface OpenRouterModelRecord { }; } -async function fetchOpenRouterImageModels(): Promise[]> { - try { - console.log("Fetching image models from OpenRouter API..."); - const response = await fetch(`${OPENROUTER_BASE_URL}/models?output_modalities=image`); - const data = (await response.json()) as { data?: OpenRouterModelRecord[] }; - const models: ImagesModel<"openrouter-images">[] = []; - - for (const model of data.data ?? []) { - const input = Array.from( - new Set( - (model.architecture?.input_modalities ?? []) - .filter((modality): modality is "text" | "image" => modality === "text" || modality === "image"), +export function parseOpenRouterImageModels( + payload: unknown, + strict: boolean, +): ImagesModel<"openrouter-images">[] { + const data = + typeof payload === "object" && payload !== null + ? (payload as { data?: OpenRouterModelRecord[] }).data + : undefined; + if (!Array.isArray(data) || data.length === 0) { + if (strict) throw new Error("OpenRouter API returned a missing or empty image model list"); + return []; + } + + const models: ImagesModel<"openrouter-images">[] = []; + for (const model of data) { + const input = Array.from( + new Set( + (model.architecture?.input_modalities ?? []).filter( + (modality): modality is "text" | "image" => modality === "text" || modality === "image", ), - ); - const output = Array.from( - new Set( - (model.architecture?.output_modalities ?? []).filter( - (modality): modality is "text" | "image" => modality === "text" || modality === "image", - ), + ), + ); + const output = Array.from( + new Set( + (model.architecture?.output_modalities ?? []).filter( + (modality): modality is "text" | "image" => modality === "text" || modality === "image", ), - ); - - if (!output.includes("image")) continue; - if (input.length === 0) input.push("text"); - - models.push({ - id: model.id, - name: model.name, - api: "openrouter-images", - provider: "openrouter", - baseUrl: OPENROUTER_BASE_URL, - input, - output, - cost: { - input: parseFloat(model.pricing?.prompt || "0") * 1_000_000, - output: parseFloat(model.pricing?.completion || "0") * 1_000_000, - cacheRead: parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000, - cacheWrite: parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000, - }, - }); - } + ), + ); + + if (!output.includes("image")) continue; + if (input.length === 0) input.push("text"); + models.push({ + id: model.id, + name: model.name, + api: "openrouter-images", + provider: "openrouter", + baseUrl: OPENROUTER_BASE_URL, + input, + output, + cost: { + input: parseFloat(model.pricing?.prompt || "0") * 1_000_000, + output: parseFloat(model.pricing?.completion || "0") * 1_000_000, + cacheRead: parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000, + cacheWrite: parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000, + }, + }); + } + + if (strict && models.length === 0) { + throw new Error("OpenRouter API returned no usable image models"); + } + return models; +} + +async function fetchOpenRouterImageModels(strict: boolean): Promise[]> { + try { + console.log("Fetching image models from OpenRouter API..."); + const response = await fetch(`${OPENROUTER_BASE_URL}/models?output_modalities=image`); + if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`); + const models = parseOpenRouterImageModels(await response.json(), strict); console.log(`Fetched ${models.length} image models from OpenRouter`); return models; } catch (error) { console.error("Failed to fetch OpenRouter image models:", error); + if (strict) throw error; return []; } } @@ -118,14 +146,17 @@ ${providerEntries} } async function main(): Promise { - const models = await fetchOpenRouterImageModels(); + const strict = readStrictOption(process.argv.slice(2)); + const models = await fetchOpenRouterImageModels(strict); const output = generateImageModelsFile(models); const outputPath = join(packageRoot, "src", "image-models.generated.ts"); writeFileSync(outputPath, output, "utf-8"); console.log(`Generated ${outputPath}`); } -main().catch((error) => { - console.error(error); - process.exit(1); -}); +if (process.argv[1] && resolve(process.argv[1]) === __filename) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 53d5a00a1ef..66e64e5a11e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { mkdirSync, readdirSync, rmSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "fs"; import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { @@ -18,6 +18,14 @@ import type { OpenAICompletionsCompat, OpenAIResponsesCompat, } from "../src/types.ts"; +import { + createModelDataManifest, + type ModelDataStructure, + MODEL_DATA_MANIFEST_FILE, + readModelDataStructure, + validateGeneratedModelData, + validateModelDataDirectory, +} from "./model-data.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -25,11 +33,13 @@ const packageRoot = join(__dirname, ".."); function readGeneratorOptions(args: string[]): { strict: boolean; + dataOnly: boolean; jsonOnly: boolean; jsonOutputDir: string | undefined; pretty: boolean; } { let strict = false; + let dataOnly = false; let jsonOnly = false; let jsonOutputDir: string | undefined; let pretty = false; @@ -40,6 +50,10 @@ function readGeneratorOptions(args: string[]): { strict = true; continue; } + if (arg === "--data-only") { + dataOnly = true; + continue; + } if (arg === "--json-only") { jsonOnly = true; continue; @@ -58,7 +72,8 @@ function readGeneratorOptions(args: string[]): { } if (jsonOnly && !jsonOutputDir) throw new Error("--json-only requires --json-output"); - return { strict, jsonOnly, jsonOutputDir, pretty }; + if (dataOnly && (jsonOnly || jsonOutputDir)) throw new Error("--data-only cannot be combined with JSON catalog output"); + return { strict, dataOnly, jsonOnly, jsonOutputDir, pretty }; } const generatorOptions = readGeneratorOptions(process.argv.slice(2)); @@ -245,9 +260,9 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = { const KIMI_K3_THINKING_LEVEL_MAP = { off: null, minimal: null, - low: null, + low: "low", medium: null, - high: null, + high: "high", xhigh: null, max: "max", } as const; @@ -261,7 +276,6 @@ const KIMI_K3_COST = { // Kimi Coding is subscription-backed, so models.dev reports zero cost. Use the // equivalent Moonshot API rates to estimate the value of subscription usage. const KIMI_CODING_IMPLIED_COSTS: Record["cost"]> = { - k2p7: { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, k3: KIMI_K3_COST, "kimi-for-coding": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, "kimi-for-coding-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 }, @@ -1674,12 +1688,12 @@ async function loadModelsDevData(): Promise[]> { const kimiModels = data["kimi-for-coding"].models as Record; const hasCanonicalModel = Object.prototype.hasOwnProperty.call(kimiModels, "kimi-for-coding"); - const kimiAliases = new Set(["k2p5", "k2p6"]); + const kimiAliases = new Set(["k2p5", "k2p6", "k2p7"]); for (const [modelId, model] of Object.entries(kimiModels)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; - // models.dev may expose versioned aliases (e.g. k2p5/k2p6). + // models.dev may expose versioned aliases (e.g. k2p5/k2p6/k2p7). // Normalize aliases to the canonical model id and drop duplicates when canonical exists. if (kimiAliases.has(modelId) && hasCanonicalModel) continue; @@ -1826,6 +1840,57 @@ async function loadModelsDevData(): Promise[]> { } } + // Process Alibaba Cloud Model Studio Token Plan models + // Two regions (international / cn) with identical catalogs, separate + // endpoints and API keys (sk-sp- prefix). models.dev keys are + // "alibaba-token-plan[-cn]"; pi exposes them as "qwen-token-plan[-cn]". + const qwenTokenPlanCompat: OpenAICompletionsCompat = { + thinkingFormat: "qwen", + supportsDeveloperRole: false, + supportsStore: false, + }; + const qwenTokenPlanVariants = [ + { + source: "alibaba-token-plan", + provider: "qwen-token-plan", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + }, + { + source: "alibaba-token-plan-cn", + provider: "qwen-token-plan-cn", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + }, + ] as const; + + for (const { source, provider, baseUrl } of qwenTokenPlanVariants) { + const providerModels = data[source]?.models; + if (!providerModels) continue; + + for (const [modelId, model] of Object.entries(providerModels)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-completions", + provider, + baseUrl, + compat: qwenTokenPlanCompat, + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + } + } + console.log(`Loaded ${models.length} tool-capable models from models.dev`); return models; } catch (error) { @@ -2113,10 +2178,10 @@ async function generateModels() { // OpenAI Codex (ChatGPT OAuth) models // NOTE: These are not fetched from models.dev; we keep a small, explicit list to avoid aliases. - // Older model limits are based on observed server behavior; GPT-5.6 follows Codex's 372k catalog limit. + // Older model limits are based on observed server behavior; GPT-5.6 follows Codex's 272k catalog limit (formerly 372k). const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const CODEX_CONTEXT = 272000; - const CODEX_GPT_56_CONTEXT = 372000; + const CODEX_GPT_56_CONTEXT = 272000; const CODEX_SPARK_CONTEXT = 128000; const CODEX_MAX_TOKENS = 128000; const codexModels: Model<"openai-codex-responses">[] = [ @@ -2228,6 +2293,29 @@ async function generateModels() { }); } + // Add qwen3.8-max-preview to Qwen Token Plan providers until models.dev includes it + for (const qwenTpProvider of ["qwen-token-plan", "qwen-token-plan-cn"] as const) { + if (!allModels.some((m) => m.provider === qwenTpProvider && m.id === "qwen3.8-max-preview")) { + const baseUrl = + qwenTpProvider === "qwen-token-plan" + ? "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" + : "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; + allModels.push({ + id: "qwen3.8-max-preview", + name: "Qwen3.8 Max Preview", + api: "openai-completions", + provider: qwenTpProvider, + baseUrl, + compat: { thinkingFormat: "qwen", supportsDeveloperRole: false, supportsStore: false } satisfies OpenAICompletionsCompat, + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 65536, + }); + } + } + // Add "auto" alias for openrouter/auto if (!allModels.some(m => m.provider === "openrouter" && m.id === "auto")) { allModels.push({ @@ -2330,63 +2418,154 @@ async function generateModels() { jsonProviders[providerId][modelId] = providers[providerId][modelId]; } } - const writeJson = (path: string, value: unknown) => - writeFileSync(path, `${JSON.stringify(value, null, generatorOptions.pretty ? 2 : undefined)}\n`); + + const serializeJson = (value: unknown) => `${JSON.stringify(value, null, generatorOptions.pretty ? 2 : undefined)}\n`; + const writeJson = (path: string, value: unknown) => writeFileSync(path, serializeJson(value)); + let generatedDataProviderIds = sortedProviderIds; + let generatedDataProviders = jsonProviders; + let modelDataStructure: ModelDataStructure = Object.fromEntries( + sortedProviderIds.map((providerId) => [ + providerId, + Object.fromEntries( + Object.entries(jsonProviders[providerId]).map(([modelId, model]) => [modelId, model.api]), + ), + ]), + ); + + if (generatorOptions.dataOnly) { + modelDataStructure = readModelDataStructure(packageRoot); + generatedDataProviderIds = Object.keys(modelDataStructure); + const hydratedProviders: typeof jsonProviders = {}; + const hydrationErrors: string[] = []; + for (const [providerId, expectedModels] of Object.entries(modelDataStructure)) { + hydratedProviders[providerId] = {}; + for (const [modelId, expectedApi] of Object.entries(expectedModels)) { + const model = jsonProviders[providerId]?.[modelId]; + if (!model) { + hydrationErrors.push(`missing ${providerId}/${modelId}`); + continue; + } + if (model.api !== expectedApi) { + hydrationErrors.push(`${providerId}/${modelId} uses ${model.api}, expected ${expectedApi}`); + continue; + } + hydratedProviders[providerId][modelId] = model; + } + } + if (hydrationErrors.length > 0) { + throw new Error(`Cannot hydrate the committed model catalog:\n${hydrationErrors.map((error) => ` - ${error}`).join("\n")}`); + } + generatedDataProviders = hydratedProviders; + } if (!generatorOptions.jsonOnly) { - // Generate TypeScript structural catalogs and adjacent JSON values. - const generatedHeader = `// This file is auto-generated by scripts/generate-models.ts + // Stage and validate all provider values before replacing the current generated data. + const providersDir = join(packageRoot, "src/providers"); + const dataDir = join(providersDir, "data"); + const stagingRoot = mkdtempSync(join(providersDir, ".model-generation-")); + const stagedDataDir = join(stagingRoot, "data"); + const previousDataDir = join(stagingRoot, "previous-data"); + let restoreStructuralCatalog: (() => void) | undefined; + try { + mkdirSync(stagedDataDir, { recursive: true }); + const fileContents: Record = {}; + for (const providerId of generatedDataProviderIds) { + const filename = `${providerId}.json`; + const content = serializeJson(generatedDataProviders[providerId]); + fileContents[filename] = content; + writeFileSync(join(stagedDataDir, filename), content); + } + writeJson( + join(stagedDataDir, MODEL_DATA_MANIFEST_FILE), + createModelDataManifest(modelDataStructure, fileContents), + ); + validateModelDataDirectory(modelDataStructure, stagedDataDir); + + if (!generatorOptions.dataOnly) { + // Generate TypeScript structural catalogs only after the model data is complete and valid. + const previousShardContents = new Map( + readdirSync(providersDir) + .filter((entry) => entry.endsWith(".models.ts")) + .map((entry) => [entry, readFileSync(join(providersDir, entry), "utf8")] as const), + ); + const aggregatorPath = join(packageRoot, "src/models.generated.ts"); + const previousAggregator = readFileSync(aggregatorPath, "utf8"); + restoreStructuralCatalog = () => { + for (const entry of readdirSync(providersDir)) { + if (entry.endsWith(".models.ts")) rmSync(join(providersDir, entry)); + } + for (const [entry, content] of previousShardContents) { + writeFileSync(join(providersDir, entry), content); + } + writeFileSync(aggregatorPath, previousAggregator); + }; + + const generatedHeader = `// This file is auto-generated by scripts/generate-models.ts // Do not edit manually - run 'npm run generate-models' to update `; - const catalogConstName = (providerId: string) => - `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`; - const providersDir = join(packageRoot, "src/providers"); - const dataDir = join(providersDir, "data"); + const catalogConstName = (providerId: string) => + `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`; + const generatedShardFiles = new Set(); - function emitModelShape(model: Model, indent: string): string { - return `${indent}${JSON.stringify(model.id)}: Model<${JSON.stringify(model.api)}> & {\n${indent}\tid: ${JSON.stringify(model.id)};\n${indent}\tprovider: ${JSON.stringify(model.provider)};\n${indent}};\n`; - } + function emitModelShape(model: Model, indent: string): string { + return `${indent}${JSON.stringify(model.id)}: Model<${JSON.stringify(model.api)}> & {\n${indent}\tid: ${JSON.stringify(model.id)};\n${indent}\tprovider: ${JSON.stringify(model.provider)};\n${indent}};\n`; + } - // Remove stale per-provider catalogs and their generated values. - for (const entry of readdirSync(providersDir)) { - if (entry.endsWith(".models.ts")) { - rmSync(join(providersDir, entry)); - } - } - rmSync(dataDir, { recursive: true, force: true }); - mkdirSync(dataDir, { recursive: true }); + for (const providerId of sortedProviderIds) { + const models = providers[providerId]; + let output = generatedHeader; + output += `import values from "./data/${providerId}.json" with { type: "json" };\n`; + output += `import type { Model } from "../types.ts";\n\n`; + output += `export const ${catalogConstName(providerId)} = values as {\n`; + for (const modelId of Object.keys(models).sort()) { + output += emitModelShape(models[modelId], "\t"); + } + output += `};\n`; + const filename = `${providerId}.models.ts`; + generatedShardFiles.add(filename); + writeFileSync(join(providersDir, filename), output); + } + for (const entry of readdirSync(providersDir)) { + if (entry.endsWith(".models.ts") && !generatedShardFiles.has(entry)) rmSync(join(providersDir, entry)); + } + console.log(`Generated ${sortedProviderIds.length} catalog structures under src/providers/`); - // Per-provider catalog structure and values (sorted for deterministic output). - for (const providerId of sortedProviderIds) { - const models = providers[providerId]; - const sortedModelIds = Object.keys(models).sort(); - let output = generatedHeader; - output += `import values from "./data/${providerId}.json" with { type: "json" };\n`; - output += `import type { Model } from "../types.ts";\n\n`; - output += `export const ${catalogConstName(providerId)} = values as {\n`; - for (const modelId of sortedModelIds) { - output += emitModelShape(models[modelId], "\t"); + let output = generatedHeader; + for (const providerId of sortedProviderIds) { + output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`; + } + output += `\nexport const MODELS = {\n`; + for (const providerId of sortedProviderIds) { + output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`; + } + output += `} as const;\n`; + writeFileSync(aggregatorPath, output); + console.log("Generated src/models.generated.ts"); } - output += `};\n`; - writeFileSync(join(providersDir, `${providerId}.models.ts`), output); - writeJson(join(dataDir, `${providerId}.json`), jsonProviders[providerId]); - } - console.log(`Generated ${sortedProviderIds.length} catalog structures under src/providers/`); - console.log("Generated JSON model values under src/providers/data/"); - // Aggregator - let output = generatedHeader; - for (const providerId of sortedProviderIds) { - output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`; - } - output += `\nexport const MODELS = {\n`; - for (const providerId of sortedProviderIds) { - output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`; + const hadPreviousData = existsSync(dataDir); + if (hadPreviousData) renameSync(dataDir, previousDataDir); + try { + renameSync(stagedDataDir, dataDir); + validateGeneratedModelData(packageRoot); + } catch (error) { + rmSync(dataDir, { recursive: true, force: true }); + if (hadPreviousData && existsSync(previousDataDir)) renameSync(previousDataDir, dataDir); + throw error; + } + restoreStructuralCatalog = undefined; + console.log( + generatorOptions.dataOnly + ? "Hydrated JSON model values under src/providers/data/" + : "Generated JSON model values under src/providers/data/", + ); + } catch (error) { + restoreStructuralCatalog?.(); + throw error; + } finally { + rmSync(stagingRoot, { recursive: true, force: true }); } - output += `} as const;\n`; - writeFileSync(join(packageRoot, "src/models.generated.ts"), output); - console.log("Generated src/models.generated.ts"); } if (generatorOptions.jsonOutputDir) { diff --git a/packages/ai/scripts/model-data.ts b/packages/ai/scripts/model-data.ts new file mode 100644 index 00000000000..588bc695689 --- /dev/null +++ b/packages/ai/scripts/model-data.ts @@ -0,0 +1,261 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +export const MODEL_DATA_SCHEMA_VERSION = 1; +export const MODEL_DATA_MANIFEST_FILE = ".manifest.json"; + +export type ModelDataStructure = Record>; + +export interface ModelDataManifest { + schemaVersion: number; + structureHash: string; + files: Record; +} + +const JSON_STRING_PATTERN = '"(?:\\\\.|[^"\\\\])*"'; +const MODEL_SHAPE_PATTERN = new RegExp(`^\\t(${JSON_STRING_PATTERN}): Model<(${JSON_STRING_PATTERN})> & \\{$`); +const MODEL_ID_PATTERN = new RegExp(`^\\t\\tid: (${JSON_STRING_PATTERN});$`); +const MODEL_PROVIDER_PATTERN = new RegExp(`^\\t\\tprovider: (${JSON_STRING_PATTERN});$`); + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function parseJsonString(value: string, description: string): string { + const parsed: unknown = JSON.parse(value); + if (typeof parsed !== "string") throw new Error(`${description} is not a string`); + return parsed; +} + +function sortedRecord(entries: Iterable): Record { + return Object.fromEntries(Array.from(entries).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); +} + +function sameStrings(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function describeSetDifference(expected: readonly string[], actual: readonly string[]): string { + const expectedSet = new Set(expected); + const actualSet = new Set(actual); + const missing = expected.filter((value) => !actualSet.has(value)); + const extra = actual.filter((value) => !expectedSet.has(value)); + return [missing.length > 0 ? `missing: ${missing.join(", ")}` : "", extra.length > 0 ? `extra: ${extra.join(", ")}` : ""] + .filter(Boolean) + .join("; "); +} + +function parseProviderStructure(path: string, providerId: string): Record { + const source = readFileSync(path, "utf8"); + const expectedImport = `import values from "./data/${providerId}.json" with { type: "json" };`; + if (!source.includes(expectedImport)) { + throw new Error(`${path} does not import ${providerId}.json`); + } + + const models = new Map(); + const lines = source.split("\n"); + for (let index = 0; index < lines.length; index++) { + const shapeMatch = MODEL_SHAPE_PATTERN.exec(lines[index]); + if (!shapeMatch) continue; + + const idMatch = MODEL_ID_PATTERN.exec(lines[index + 1] ?? ""); + const providerMatch = MODEL_PROVIDER_PATTERN.exec(lines[index + 2] ?? ""); + if (!idMatch || !providerMatch || lines[index + 3] !== "\t};") { + throw new Error(`${path}:${index + 1} has a malformed generated model declaration`); + } + + const key = parseJsonString(shapeMatch[1], `${path}:${index + 1} model key`); + const api = parseJsonString(shapeMatch[2], `${path}:${index + 1} model API`); + const id = parseJsonString(idMatch[1], `${path}:${index + 2} model ID`); + const provider = parseJsonString(providerMatch[1], `${path}:${index + 3} provider ID`); + if (id !== key) throw new Error(`${path}:${index + 1} declares key ${key} with ID ${id}`); + if (provider !== providerId) { + throw new Error(`${path}:${index + 1} declares provider ${provider} instead of ${providerId}`); + } + if (models.has(key)) throw new Error(`${path} declares model ${key} more than once`); + models.set(key, api); + index += 3; + } + + if (models.size === 0) throw new Error(`${path} contains no generated model declarations`); + return sortedRecord(models); +} + +export function readModelDataStructure(packageRoot: string): ModelDataStructure { + const providersDir = join(packageRoot, "src", "providers"); + const shardProviderIds = readdirSync(providersDir) + .filter((entry) => entry.endsWith(".models.ts")) + .map((entry) => entry.slice(0, -".models.ts".length)) + .sort(); + if (shardProviderIds.length === 0) throw new Error(`No generated provider shards found under ${providersDir}`); + + const aggregator = readFileSync(join(packageRoot, "src", "models.generated.ts"), "utf8"); + const importedProviderIds = Array.from( + aggregator.matchAll(/^import \{ [A-Z0-9_]+_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm), + (match) => match[1], + ).sort(); + if (!sameStrings(shardProviderIds, importedProviderIds)) { + throw new Error( + `Generated model aggregator and provider shards do not match (${describeSetDifference(shardProviderIds, importedProviderIds)})`, + ); + } + + return sortedRecord( + shardProviderIds.map((providerId) => [ + providerId, + parseProviderStructure(join(providersDir, `${providerId}.models.ts`), providerId), + ] as const), + ); +} + +export function modelDataStructureHash(structure: ModelDataStructure): string { + return sha256(JSON.stringify(structure)); +} + +export function createModelDataManifest( + structure: ModelDataStructure, + fileContents: Readonly>, +): ModelDataManifest { + return { + schemaVersion: MODEL_DATA_SCHEMA_VERSION, + structureHash: modelDataStructureHash(structure), + files: sortedRecord(Object.entries(fileContents).map(([file, content]) => [file, sha256(content)] as const)), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readJsonObject(path: string, description: string, errors: string[]): Record | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + errors.push(`${description} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + if (!isRecord(parsed)) { + errors.push(`${description} must contain a JSON object`); + return undefined; + } + return parsed; +} + +function validateModelValue( + value: unknown, + providerId: string, + modelId: string, + expectedApi: string, + errors: string[], +): void { + const label = `${providerId}/${modelId}`; + if (!isRecord(value)) { + errors.push(`${label} must be an object`); + return; + } + if (value.id !== modelId) errors.push(`${label} has id ${JSON.stringify(value.id)}, expected ${JSON.stringify(modelId)}`); + if (value.provider !== providerId) { + errors.push(`${label} has provider ${JSON.stringify(value.provider)}, expected ${JSON.stringify(providerId)}`); + } + if (value.api !== expectedApi) { + errors.push(`${label} has api ${JSON.stringify(value.api)}, expected ${JSON.stringify(expectedApi)}`); + } + if (typeof value.name !== "string" || value.name.length === 0) errors.push(`${label} has no model name`); + if (typeof value.baseUrl !== "string") errors.push(`${label} has no baseUrl string`); + if (typeof value.reasoning !== "boolean") errors.push(`${label} has no reasoning boolean`); + if ( + !Array.isArray(value.input) || + value.input.length === 0 || + value.input.some((entry) => entry !== "text" && entry !== "image") + ) { + errors.push(`${label} has invalid input modalities`); + } + if (typeof value.contextWindow !== "number" || !Number.isFinite(value.contextWindow) || value.contextWindow <= 0) { + errors.push(`${label} has invalid contextWindow`); + } + if (typeof value.maxTokens !== "number" || !Number.isFinite(value.maxTokens) || value.maxTokens <= 0) { + errors.push(`${label} has invalid maxTokens`); + } + if (!isRecord(value.cost)) { + errors.push(`${label} has invalid cost metadata`); + } else { + for (const field of ["input", "output", "cacheRead", "cacheWrite"] as const) { + const cost = value.cost[field]; + if (typeof cost !== "number" || !Number.isFinite(cost)) { + errors.push(`${label} has invalid cost.${field}`); + } + } + } +} + +function throwValidationErrors(errors: string[]): never { + const visible = errors.slice(0, 30); + const suffix = errors.length > visible.length ? `\n ... and ${errors.length - visible.length} more` : ""; + throw new Error(`Invalid generated model data:\n${visible.map((error) => ` - ${error}`).join("\n")}${suffix}`); +} + +export function validateModelDataDirectory(structure: ModelDataStructure, dataDir: string): void { + if (!existsSync(dataDir) || !statSync(dataDir).isDirectory()) { + throw new Error(`Generated model data directory does not exist: ${dataDir}`); + } + + const errors: string[] = []; + const expectedFiles = Object.keys(structure) + .map((providerId) => `${providerId}.json`) + .sort(); + const actualFiles = readdirSync(dataDir) + .filter((entry) => entry.endsWith(".json") && entry !== MODEL_DATA_MANIFEST_FILE) + .sort(); + if (!sameStrings(expectedFiles, actualFiles)) { + errors.push(`provider data files do not match the structural catalog (${describeSetDifference(expectedFiles, actualFiles)})`); + } + + const manifestPath = join(dataDir, MODEL_DATA_MANIFEST_FILE); + const manifest = readJsonObject(manifestPath, "model data manifest", errors); + if (manifest?.schemaVersion !== MODEL_DATA_SCHEMA_VERSION) { + errors.push( + `model data schema is ${JSON.stringify(manifest?.schemaVersion)}, expected ${MODEL_DATA_SCHEMA_VERSION}`, + ); + } + const expectedStructureHash = modelDataStructureHash(structure); + if (manifest?.structureHash !== expectedStructureHash) { + errors.push("model data generation stamp does not match the structural catalog"); + } + const manifestFiles = isRecord(manifest?.files) ? manifest.files : undefined; + if (!manifestFiles) errors.push("model data manifest has no file hashes"); + else { + const manifestFileNames = Object.keys(manifestFiles).sort(); + if (!sameStrings(expectedFiles, manifestFileNames)) { + errors.push(`manifest file hashes do not match provider data files (${describeSetDifference(expectedFiles, manifestFileNames)})`); + } + } + + for (const [providerId, expectedModels] of Object.entries(structure)) { + const filename = `${providerId}.json`; + const path = join(dataDir, filename); + if (!existsSync(path)) continue; + const content = readFileSync(path, "utf8"); + if (manifestFiles && manifestFiles[filename] !== sha256(content)) { + errors.push(`${filename} does not match its manifest hash`); + } + const values = readJsonObject(path, filename, errors); + if (!values) continue; + const expectedModelIds = Object.keys(expectedModels).sort(); + const actualModelIds = Object.keys(values).sort(); + if (!sameStrings(expectedModelIds, actualModelIds)) { + errors.push(`${filename} model IDs do not match the structural catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`); + } + for (const [modelId, api] of Object.entries(expectedModels)) { + if (modelId in values) validateModelValue(values[modelId], providerId, modelId, api, errors); + } + } + + if (errors.length > 0) throwValidationErrors(errors); +} + +export function validateGeneratedModelData(packageRoot: string): void { + const structure = readModelDataStructure(packageRoot); + validateModelDataDirectory(structure, join(packageRoot, "src", "providers", "data")); +} diff --git a/packages/ai/src/api/openai-codex-responses.ts b/packages/ai/src/api/openai-codex-responses.ts index de0d17925cb..955964fecc9 100644 --- a/packages/ai/src/api/openai-codex-responses.ts +++ b/packages/ai/src/api/openai-codex-responses.ts @@ -46,6 +46,7 @@ import { formatProviderError, normalizeProviderError } from "../utils/error-body import { AssistantMessageEventStream } from "../utils/event-stream.ts"; import { headersToRecord } from "../utils/headers.ts"; import { resolveHttpProxyUrlForTarget } from "../utils/node-http-proxy.ts"; +import { uuidv7 } from "../utils/uuid.ts"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts"; import { buildBaseOptions } from "./simple-options.ts"; @@ -259,7 +260,7 @@ export const stream: StreamFunction<"openai-codex-responses", OpenAICodexRespons body = nextBody as RequestBody; } const codexSessionId = clampOpenAIPromptCacheKey(options?.sessionId); - const websocketRequestId = codexSessionId || createCodexRequestId(); + const websocketRequestId = codexSessionId || uuidv7(); const sseHeaders = buildSSEHeaders(model.headers, options?.headers, accountId, apiKey, codexSessionId); const websocketHeaders = buildWebSocketHeaders( model.headers, @@ -1505,13 +1506,6 @@ function extractAccountId(token: string): string { } } -function createCodexRequestId(): string { - if (typeof globalThis.crypto?.randomUUID === "function") { - return globalThis.crypto.randomUUID(); - } - return `codex_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; -} - function buildBaseCodexHeaders( initHeaders: Record | undefined, additionalHeaders: ProviderHeaders | undefined, diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 61001777270..01eefcb05d3 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -34,6 +34,7 @@ import type { } from "../types.ts"; import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts"; import { AssistantMessageEventStream } from "../utils/event-stream.ts"; +import { shortHash } from "../utils/hash.ts"; import { headersToRecord } from "../utils/headers.ts"; import { parseStreamingJson } from "../utils/json-parse.ts"; import { getProviderEnvValue } from "../utils/provider-env.ts"; @@ -895,10 +896,21 @@ export function convertMessages( // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =) // These come from providers like github-copilot, openai-codex, opencode // Extract just the call_id part and normalize it + // Multiple tool calls in the same turn can share call_id but differ by item_id. + // Preserve item-level uniqueness when replaying into Chat Completions, which + // requires distinct tool call ids. if (id.includes("|")) { - const [callId] = id.split("|"); // Sanitize to allowed chars and truncate to 40 chars (OpenAI limit) - return callId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40); + const separatorIndex = id.indexOf("|"); + const callId = id.slice(0, separatorIndex).replace(/[^a-zA-Z0-9_-]/g, "_"); + const itemId = id.slice(separatorIndex + 1).replace(/[^a-zA-Z0-9_-]/g, "_"); + const combinedId = itemId.length > 0 ? `${callId}_${itemId}` : callId; + if (combinedId.length <= 40) { + return combinedId; + } + const hash = shortHash(id).slice(0, 8); + const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1)); + return `${prefix}_${hash}`; } if (model.provider === "openai") return id.length > 40 ? id.slice(0, 40) : id; diff --git a/packages/ai/src/api/pi-messages.ts b/packages/ai/src/api/pi-messages.ts index 0af13f88f1c..ee83f4ec6bc 100644 --- a/packages/ai/src/api/pi-messages.ts +++ b/packages/ai/src/api/pi-messages.ts @@ -103,14 +103,11 @@ export class PiMessagesResponseError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parsePiMessagesErrorBody(body: string): PiMessagesErrorBody | undefined { try { - const parsed = JSON.parse(body) as unknown; - return isRecord(parsed) && isRecord(parsed.error) ? (parsed as PiMessagesErrorBody) : undefined; + const parsed = JSON.parse(body) as PiMessagesErrorBody | null; + const error = parsed?.error; + return parsed && typeof error === "object" && error !== null && !Array.isArray(error) ? parsed : undefined; } catch { return undefined; } diff --git a/packages/ai/src/auth/helpers.ts b/packages/ai/src/auth/helpers.ts index 5c64535f43e..251c60da1eb 100644 --- a/packages/ai/src/auth/helpers.ts +++ b/packages/ai/src/auth/helpers.ts @@ -14,7 +14,9 @@ export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyA return { type: "api_key", key }; }, resolve: async ({ ctx, credential }) => { - if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored credential" }; + if (credential?.key) { + return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" }; + } for (const envVar of envVars) { const value = await ctx.env(envVar); if (value) return { auth: { apiKey: value }, source: envVar }; diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 7a4f8f08f99..e6fa23dcaeb 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -73,6 +73,8 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { const envMap: Record = { "ant-ling": "ANT_LING_API_KEY", + "qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY", + "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY", openai: "OPENAI_API_KEY", "azure-openai-responses": "AZURE_OPENAI_API_KEY", nvidia: "NVIDIA_API_KEY", diff --git a/packages/ai/src/image-models.generated.ts b/packages/ai/src/image-models.generated.ts index 573386613b6..f89ae02bae7 100644 --- a/packages/ai/src/image-models.generated.ts +++ b/packages/ai/src/image-models.generated.ts @@ -170,6 +170,51 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "krea/krea-2-large": { + id: "krea/krea-2-large", + name: "Krea: Krea 2 Large", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "krea/krea-2-medium": { + id: "krea/krea-2-medium", + name: "Krea: Krea 2 Medium", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, + "krea/krea-2-medium-turbo": { + id: "krea/krea-2-medium-turbo", + name: "Krea: Krea 2 Medium Turbo", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "microsoft/mai-image-2.5": { id: "microsoft/mai-image-2.5", name: "Microsoft: MAI-Image-2.5", @@ -290,6 +335,21 @@ export const IMAGE_MODELS = { cacheWrite: 0, }, } satisfies ImagesModel<"openrouter-images">, + "openrouter/auto-beta": { + id: "openrouter/auto-beta", + name: "Auto Router (Beta)", + api: "openrouter-images", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + input: ["text", "image"], + output: ["text", "image"], + cost: { + input: -1000000, + output: -1000000, + cacheRead: 0, + cacheWrite: 0, + }, + } satisfies ImagesModel<"openrouter-images">, "recraft/recraft-v3": { id: "recraft/recraft-v3", name: "Recraft: Recraft V3", diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 1056dc5aea0..4ff678102d5 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -41,5 +41,7 @@ export * from "./utils/event-stream.ts"; export * from "./utils/json-parse.ts"; export * from "./utils/overflow.ts"; export * from "./utils/retry.ts"; +export { contentText } from "./utils/text.ts"; export * from "./utils/typebox-helpers.ts"; +export { uuidv7 } from "./utils/uuid.ts"; export * from "./utils/validation.ts"; diff --git a/packages/ai/src/models-store.ts b/packages/ai/src/models-store.ts index 020edb1f2c0..3bb2b57e8fd 100644 --- a/packages/ai/src/models-store.ts +++ b/packages/ai/src/models-store.ts @@ -2,6 +2,8 @@ import type { Api, Model } from "./types.ts"; export interface ModelsStoreEntry { models: readonly Model[]; + /** Unix timestamp from the remote catalog's Last-Modified header. */ + lastModified?: number; /** Unix timestamp of the last completed remote check. */ checkedAt?: number; } diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 0129ddeee00..035c50b03a7 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -27,6 +27,8 @@ import { OPENAI_CODEX_MODELS } from "./providers/openai-codex.models.ts"; import { OPENCODE_MODELS } from "./providers/opencode.models.ts"; import { OPENCODE_GO_MODELS } from "./providers/opencode-go.models.ts"; import { OPENROUTER_MODELS } from "./providers/openrouter.models.ts"; +import { QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts"; +import { QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts"; import { TOGETHER_MODELS } from "./providers/together.models.ts"; import { VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts"; import { XAI_MODELS } from "./providers/xai.models.ts"; @@ -64,6 +66,8 @@ export const MODELS = { "opencode": OPENCODE_MODELS, "opencode-go": OPENCODE_GO_MODELS, "openrouter": OPENROUTER_MODELS, + "qwen-token-plan": QWEN_TOKEN_PLAN_MODELS, + "qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_MODELS, "together": TOGETHER_MODELS, "vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS, "xai": XAI_MODELS, diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 1261ed099cd..549880e33bb 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -29,6 +29,8 @@ import { opencodeProvider } from "./opencode.ts"; import { opencodeGoProvider } from "./opencode-go.ts"; import { openrouterProvider } from "./openrouter.ts"; import { openrouterImagesProvider } from "./openrouter-images.ts"; +import { qwenTokenPlanProvider } from "./qwen-token-plan.ts"; +import { qwenTokenPlanCnProvider } from "./qwen-token-plan-cn.ts"; import { radiusProvider } from "./radius.ts"; import { togetherProvider } from "./together.ts"; import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts"; @@ -65,6 +67,11 @@ export function getBuiltinProviders(): BuiltinProvider[] { return Object.keys(MODELS) as BuiltinProvider[]; } +/** URL of a generated provider catalog, used to compare its mtime with remote catalogs during development. */ +export function getBuiltinModelDataUrl(provider: BuiltinProvider): URL { + return new URL(`./data/${provider}.json`, import.meta.url); +} + export function getBuiltinModels( provider: TProvider, ): Model>[] { @@ -103,6 +110,8 @@ export function builtinProviders(): Provider[] { opencodeProvider(), opencodeGoProvider(), openrouterProvider(), + qwenTokenPlanProvider(), + qwenTokenPlanCnProvider(), radiusProvider(), togetherProvider(), vercelAIGatewayProvider(), diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 83a7050fd47..61deef5cb9d 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -50,7 +50,9 @@ const bedrockAuth: ApiKeyAuth = { return { type: "api_key" }; }, resolve: async ({ ctx, credential }) => { - if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored credential" }; + if (credential?.key) { + return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" }; + } if (await ctx.env("AWS_BEARER_TOKEN_BEDROCK")) return { auth: {}, source: "AWS_BEARER_TOKEN_BEDROCK" }; if (credential?.env?.AWS_PROFILE ?? (await ctx.env("AWS_PROFILE"))) { return { diff --git a/packages/ai/src/providers/kimi-coding.models.ts b/packages/ai/src/providers/kimi-coding.models.ts index cfd4c68ec8f..ac810a75153 100644 --- a/packages/ai/src/providers/kimi-coding.models.ts +++ b/packages/ai/src/providers/kimi-coding.models.ts @@ -5,14 +5,14 @@ import values from "./data/kimi-coding.json" with { type: "json" }; import type { Model } from "../types.ts"; export const KIMI_CODING_MODELS = values as { - "k2p7": Model<"anthropic-messages"> & { - id: "k2p7"; - provider: "kimi-coding"; - }; "k3": Model<"anthropic-messages"> & { id: "k3"; provider: "kimi-coding"; }; + "kimi-for-coding": Model<"anthropic-messages"> & { + id: "kimi-for-coding"; + provider: "kimi-coding"; + }; "kimi-for-coding-highspeed": Model<"anthropic-messages"> & { id: "kimi-for-coding-highspeed"; provider: "kimi-coding"; diff --git a/packages/ai/src/providers/nvidia.models.ts b/packages/ai/src/providers/nvidia.models.ts index beacbf3daa6..ec13691903b 100644 --- a/packages/ai/src/providers/nvidia.models.ts +++ b/packages/ai/src/providers/nvidia.models.ts @@ -69,10 +69,6 @@ export const NVIDIA_MODELS = values as { id: "openai/gpt-oss-20b"; provider: "nvidia"; }; - "qwen/qwen3.5-122b-a10b": Model<"openai-completions"> & { - id: "qwen/qwen3.5-122b-a10b"; - provider: "nvidia"; - }; "stepfun-ai/step-3.5-flash": Model<"openai-completions"> & { id: "stepfun-ai/step-3.5-flash"; provider: "nvidia"; diff --git a/packages/ai/src/providers/opencode-go.models.ts b/packages/ai/src/providers/opencode-go.models.ts index 4f84e56dafb..6ae1443bd57 100644 --- a/packages/ai/src/providers/opencode-go.models.ts +++ b/packages/ai/src/providers/opencode-go.models.ts @@ -21,7 +21,7 @@ export const OPENCODE_GO_MODELS = values as { id: "glm-5.2"; provider: "opencode-go"; }; - "grok-4.5": Model<"openai-completions"> & { + "grok-4.5": Model<"openai-responses"> & { id: "grok-4.5"; provider: "opencode-go"; }; diff --git a/packages/ai/src/providers/opencode-go.ts b/packages/ai/src/providers/opencode-go.ts index 608f579b7ad..e2f341eca82 100644 --- a/packages/ai/src/providers/opencode-go.ts +++ b/packages/ai/src/providers/opencode-go.ts @@ -1,11 +1,12 @@ import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts"; import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; import { envApiKeyAuth } from "../auth/helpers.ts"; import { createProvider, type Provider } from "../models.ts"; import { OPENCODE_GO_MODELS } from "./opencode-go.models.ts"; -export function opencodeGoProvider(): Provider<"anthropic-messages" | "openai-completions"> { - return createProvider({ +export function opencodeGoProvider(): Provider<"anthropic-messages" | "openai-completions" | "openai-responses"> { + return createProvider<"anthropic-messages" | "openai-completions" | "openai-responses">({ id: "opencode-go", name: "OpenCode Zen Go", auth: { apiKey: envApiKeyAuth("OpenCode API key", ["OPENCODE_API_KEY"]) }, @@ -13,6 +14,7 @@ export function opencodeGoProvider(): Provider<"anthropic-messages" | "openai-co api: { "anthropic-messages": anthropicMessagesApi(), "openai-completions": openAICompletionsApi(), + "openai-responses": openAIResponsesApi(), }, }); } diff --git a/packages/ai/src/providers/opencode.models.ts b/packages/ai/src/providers/opencode.models.ts index fb67b4ef724..6907cb861ec 100644 --- a/packages/ai/src/providers/opencode.models.ts +++ b/packages/ai/src/providers/opencode.models.ts @@ -165,7 +165,7 @@ export const OPENCODE_MODELS = values as { id: "gpt-5.6-terra"; provider: "opencode"; }; - "grok-4.5": Model<"openai-completions"> & { + "grok-4.5": Model<"openai-responses"> & { id: "grok-4.5"; provider: "opencode"; }; @@ -173,10 +173,6 @@ export const OPENCODE_MODELS = values as { id: "grok-build-0.1"; provider: "opencode"; }; - "hy3-free": Model<"openai-completions"> & { - id: "hy3-free"; - provider: "opencode"; - }; "kimi-k2.5": Model<"openai-completions"> & { id: "kimi-k2.5"; provider: "opencode"; diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index 1b9b3e4f0ef..c2358136cb5 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -285,6 +285,10 @@ export const OPENROUTER_MODELS = values as { id: "kwaipilot/kat-coder-pro-v2.5"; provider: "openrouter"; }; + "meituan/longcat-2.0": Model<"openai-completions"> & { + id: "meituan/longcat-2.0"; + provider: "openrouter"; + }; "meta-llama/llama-3.1-70b-instruct": Model<"openai-completions"> & { id: "meta-llama/llama-3.1-70b-instruct"; provider: "openrouter"; @@ -297,10 +301,6 @@ export const OPENROUTER_MODELS = values as { id: "meta-llama/llama-3.3-70b-instruct"; provider: "openrouter"; }; - "meta-llama/llama-3.3-70b-instruct:free": Model<"openai-completions"> & { - id: "meta-llama/llama-3.3-70b-instruct:free"; - provider: "openrouter"; - }; "meta-llama/llama-4-maverick": Model<"openai-completions"> & { id: "meta-llama/llama-4-maverick"; provider: "openrouter"; @@ -821,10 +821,6 @@ export const OPENROUTER_MODELS = values as { id: "qwen/qwen3-coder-plus"; provider: "openrouter"; }; - "qwen/qwen3-coder:free": Model<"openai-completions"> & { - id: "qwen/qwen3-coder:free"; - provider: "openrouter"; - }; "qwen/qwen3-max": Model<"openai-completions"> & { id: "qwen/qwen3-max"; provider: "openrouter"; @@ -837,10 +833,6 @@ export const OPENROUTER_MODELS = values as { id: "qwen/qwen3-next-80b-a3b-instruct"; provider: "openrouter"; }; - "qwen/qwen3-next-80b-a3b-instruct:free": Model<"openai-completions"> & { - id: "qwen/qwen3-next-80b-a3b-instruct:free"; - provider: "openrouter"; - }; "qwen/qwen3-next-80b-a3b-thinking": Model<"openai-completions"> & { id: "qwen/qwen3-next-80b-a3b-thinking"; provider: "openrouter"; @@ -965,10 +957,6 @@ export const OPENROUTER_MODELS = values as { id: "tencent/hy3-preview"; provider: "openrouter"; }; - "tencent/hy3:free": Model<"openai-completions"> & { - id: "tencent/hy3:free"; - provider: "openrouter"; - }; "thedrummer/unslopnemo-12b": Model<"openai-completions"> & { id: "thedrummer/unslopnemo-12b"; provider: "openrouter"; diff --git a/packages/ai/src/providers/qwen-token-plan-cn.models.ts b/packages/ai/src/providers/qwen-token-plan-cn.models.ts new file mode 100644 index 00000000000..84976ef9419 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-cn.models.ts @@ -0,0 +1,68 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qwen-token-plan-cn.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const QWEN_TOKEN_PLAN_CN_MODELS = values as { + "MiniMax-M2.5": Model<"openai-completions"> & { + id: "MiniMax-M2.5"; + provider: "qwen-token-plan-cn"; + }; + "deepseek-v3.2": Model<"openai-completions"> & { + id: "deepseek-v3.2"; + provider: "qwen-token-plan-cn"; + }; + "deepseek-v4-flash": Model<"openai-completions"> & { + id: "deepseek-v4-flash"; + provider: "qwen-token-plan-cn"; + }; + "deepseek-v4-pro": Model<"openai-completions"> & { + id: "deepseek-v4-pro"; + provider: "qwen-token-plan-cn"; + }; + "glm-5": Model<"openai-completions"> & { + id: "glm-5"; + provider: "qwen-token-plan-cn"; + }; + "glm-5.1": Model<"openai-completions"> & { + id: "glm-5.1"; + provider: "qwen-token-plan-cn"; + }; + "glm-5.2": Model<"openai-completions"> & { + id: "glm-5.2"; + provider: "qwen-token-plan-cn"; + }; + "kimi-k2.5": Model<"openai-completions"> & { + id: "kimi-k2.5"; + provider: "qwen-token-plan-cn"; + }; + "kimi-k2.6": Model<"openai-completions"> & { + id: "kimi-k2.6"; + provider: "qwen-token-plan-cn"; + }; + "kimi-k2.7-code": Model<"openai-completions"> & { + id: "kimi-k2.7-code"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.6-flash": Model<"openai-completions"> & { + id: "qwen3.6-flash"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.6-plus": Model<"openai-completions"> & { + id: "qwen3.6-plus"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.7-max": Model<"openai-completions"> & { + id: "qwen3.7-max"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.7-plus": Model<"openai-completions"> & { + id: "qwen3.7-plus"; + provider: "qwen-token-plan-cn"; + }; + "qwen3.8-max-preview": Model<"openai-completions"> & { + id: "qwen3.8-max-preview"; + provider: "qwen-token-plan-cn"; + }; +}; diff --git a/packages/ai/src/providers/qwen-token-plan-cn.ts b/packages/ai/src/providers/qwen-token-plan-cn.ts new file mode 100644 index 00000000000..259b5a7f028 --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan-cn.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { QWEN_TOKEN_PLAN_CN_MODELS } from "./qwen-token-plan-cn.models.ts"; + +export function qwenTokenPlanCnProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-token-plan-cn", + name: "Qwen Token Plan CN", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + auth: { apiKey: envApiKeyAuth("Qwen Token Plan CN API key", ["QWEN_TOKEN_PLAN_CN_API_KEY"]) }, + models: Object.values(QWEN_TOKEN_PLAN_CN_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/qwen-token-plan.models.ts b/packages/ai/src/providers/qwen-token-plan.models.ts new file mode 100644 index 00000000000..e8f91f012ac --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan.models.ts @@ -0,0 +1,68 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/qwen-token-plan.json" with { type: "json" }; +import type { Model } from "../types.ts"; + +export const QWEN_TOKEN_PLAN_MODELS = values as { + "MiniMax-M2.5": Model<"openai-completions"> & { + id: "MiniMax-M2.5"; + provider: "qwen-token-plan"; + }; + "deepseek-v3.2": Model<"openai-completions"> & { + id: "deepseek-v3.2"; + provider: "qwen-token-plan"; + }; + "deepseek-v4-flash": Model<"openai-completions"> & { + id: "deepseek-v4-flash"; + provider: "qwen-token-plan"; + }; + "deepseek-v4-pro": Model<"openai-completions"> & { + id: "deepseek-v4-pro"; + provider: "qwen-token-plan"; + }; + "glm-5": Model<"openai-completions"> & { + id: "glm-5"; + provider: "qwen-token-plan"; + }; + "glm-5.1": Model<"openai-completions"> & { + id: "glm-5.1"; + provider: "qwen-token-plan"; + }; + "glm-5.2": Model<"openai-completions"> & { + id: "glm-5.2"; + provider: "qwen-token-plan"; + }; + "kimi-k2.5": Model<"openai-completions"> & { + id: "kimi-k2.5"; + provider: "qwen-token-plan"; + }; + "kimi-k2.6": Model<"openai-completions"> & { + id: "kimi-k2.6"; + provider: "qwen-token-plan"; + }; + "kimi-k2.7-code": Model<"openai-completions"> & { + id: "kimi-k2.7-code"; + provider: "qwen-token-plan"; + }; + "qwen3.6-flash": Model<"openai-completions"> & { + id: "qwen3.6-flash"; + provider: "qwen-token-plan"; + }; + "qwen3.6-plus": Model<"openai-completions"> & { + id: "qwen3.6-plus"; + provider: "qwen-token-plan"; + }; + "qwen3.7-max": Model<"openai-completions"> & { + id: "qwen3.7-max"; + provider: "qwen-token-plan"; + }; + "qwen3.7-plus": Model<"openai-completions"> & { + id: "qwen3.7-plus"; + provider: "qwen-token-plan"; + }; + "qwen3.8-max-preview": Model<"openai-completions"> & { + id: "qwen3.8-max-preview"; + provider: "qwen-token-plan"; + }; +}; diff --git a/packages/ai/src/providers/qwen-token-plan.ts b/packages/ai/src/providers/qwen-token-plan.ts new file mode 100644 index 00000000000..295560ba2af --- /dev/null +++ b/packages/ai/src/providers/qwen-token-plan.ts @@ -0,0 +1,15 @@ +import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; +import { envApiKeyAuth } from "../auth/helpers.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { QWEN_TOKEN_PLAN_MODELS } from "./qwen-token-plan.models.ts"; + +export function qwenTokenPlanProvider(): Provider<"openai-completions"> { + return createProvider({ + id: "qwen-token-plan", + name: "Qwen Token Plan", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + auth: { apiKey: envApiKeyAuth("Qwen Token Plan API key", ["QWEN_TOKEN_PLAN_API_KEY"]) }, + models: Object.values(QWEN_TOKEN_PLAN_MODELS), + api: openAICompletionsApi(), + }); +} diff --git a/packages/ai/src/providers/radius-config.ts b/packages/ai/src/providers/radius-config.ts index dc96a300f29..a2715aaec31 100644 --- a/packages/ai/src/providers/radius-config.ts +++ b/packages/ai/src/providers/radius-config.ts @@ -23,28 +23,29 @@ export type RadiusOAuthCredential = OAuthCredential & { gatewayConfig?: RadiusGatewayConfig; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isRadiusGatewayModel(value: unknown): value is RadiusGatewayModel { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const model = value as Partial; return ( - isRecord(value) && - typeof value.id === "string" && - typeof value.name === "string" && - typeof value.reasoning === "boolean" && - Array.isArray(value.input) && - isRecord(value.cost) && - typeof value.contextWindow === "number" && - typeof value.maxTokens === "number" + typeof model.id === "string" && + typeof model.name === "string" && + typeof model.reasoning === "boolean" && + Array.isArray(model.input) && + typeof model.cost === "object" && + model.cost !== null && + !Array.isArray(model.cost) && + typeof model.contextWindow === "number" && + typeof model.maxTokens === "number" ); } function sanitizeRadiusGatewayConfig(config: unknown): RadiusGatewayConfig | undefined { - if (!isRecord(config) || typeof config.baseUrl !== "string" || !Array.isArray(config.models)) return undefined; + if (typeof config !== "object" || config === null || Array.isArray(config)) return undefined; + const { baseUrl, models } = config as Partial; + if (typeof baseUrl !== "string" || !Array.isArray(models)) return undefined; return { - baseUrl: config.baseUrl, - models: config.models.filter(isRadiusGatewayModel).map((model) => ({ ...model })), + baseUrl, + models: models.filter(isRadiusGatewayModel).map((model) => ({ ...model })), }; } diff --git a/packages/ai/src/providers/together.models.ts b/packages/ai/src/providers/together.models.ts index 63cdb8303bb..3ae1225cb69 100644 --- a/packages/ai/src/providers/together.models.ts +++ b/packages/ai/src/providers/together.models.ts @@ -17,14 +17,6 @@ export const TOGETHER_MODELS = values as { id: "Qwen/Qwen2.5-7B-Instruct-Turbo"; provider: "together"; }; - "Qwen/Qwen3-235B-A22B-Instruct-2507-tput": Model<"openai-completions"> & { - id: "Qwen/Qwen3-235B-A22B-Instruct-2507-tput"; - provider: "together"; - }; - "Qwen/Qwen3.5-397B-A17B": Model<"openai-completions"> & { - id: "Qwen/Qwen3.5-397B-A17B"; - provider: "together"; - }; "Qwen/Qwen3.5-9B": Model<"openai-completions"> & { id: "Qwen/Qwen3.5-9B"; provider: "together"; @@ -41,10 +33,6 @@ export const TOGETHER_MODELS = values as { id: "deepseek-ai/DeepSeek-V4-Pro"; provider: "together"; }; - "essentialai/Rnj-1-Instruct": Model<"openai-completions"> & { - id: "essentialai/Rnj-1-Instruct"; - provider: "together"; - }; "google/gemma-4-31B-it": Model<"openai-completions"> & { id: "google/gemma-4-31B-it"; provider: "together"; @@ -77,14 +65,6 @@ export const TOGETHER_MODELS = values as { id: "thinkingmachines/Inkling"; provider: "together"; }; - "zai-org/GLM-5": Model<"openai-completions"> & { - id: "zai-org/GLM-5"; - provider: "together"; - }; - "zai-org/GLM-5.1": Model<"openai-completions"> & { - id: "zai-org/GLM-5.1"; - provider: "together"; - }; "zai-org/GLM-5.2": Model<"openai-completions"> & { id: "zai-org/GLM-5.2"; provider: "together"; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index c123fd89769..aeaf18c6765 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -64,6 +64,8 @@ export type KnownProvider = | "kimi-coding" | "cloudflare-workers-ai" | "cloudflare-ai-gateway" + | "qwen-token-plan" + | "qwen-token-plan-cn" | "xiaomi" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" @@ -406,6 +408,8 @@ export interface ToolResultMessage { toolName: string; content: (TextContent | ImageContent)[]; // Supports text and images details?: TDetails; + /** Usage from the tool execution itself, if available. Not part of main LLM context accounting. */ + usage?: Usage; /** * Names from `Context.tools` that became available after this result. * Providers with native deferred tool loading use this as the load point; diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index c10ee80d348..6554b0b0362 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -31,6 +31,7 @@ import type { AssistantMessage } from "../types.ts"; * - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length" * with output=0 (no room left to generate). Detected via stopReason "length" + zero output + * input filling the context window. + * - DashScope/Qwen: "Range of input length should be [1, X]" (HTTP 400 invalid_parameter_error) * - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens" */ const OVERFLOW_PATTERNS = [ @@ -54,6 +55,7 @@ const OVERFLOW_PATTERNS = [ /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4 server /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error + /range of input length should be/i, // DashScope / Qwen Token Plan /context[_ ]length[_ ]exceeded/i, // Generic fallback /too many tokens/i, // Generic fallback /token limit exceeded/i, // Generic fallback @@ -101,6 +103,7 @@ const NON_OVERFLOW_PATTERNS = [ * - LM Studio: "greater than the context length" * - Kimi For Coding: "exceeded model token limit: X (requested: Y)" * - DS4: "Prompt has X tokens, but the configured context size is Y tokens" + * - DashScope/Qwen: "Range of input length should be [1, X]" * * **Unreliable detection:** * - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow), diff --git a/packages/ai/src/utils/text.ts b/packages/ai/src/utils/text.ts new file mode 100644 index 00000000000..66f5b9c5b65 --- /dev/null +++ b/packages/ai/src/utils/text.ts @@ -0,0 +1,12 @@ +import type { ImageContent, TextContent, ThinkingContent, ToolCall } from "../types.ts"; + +type Content = TextContent | ImageContent | ThinkingContent | ToolCall; + +/** Extract and join text from message content. */ +export function contentText(content: string | readonly Content[], separator = "\n"): string { + if (typeof content === "string") return content; + return content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(separator); +} diff --git a/packages/agent/src/harness/session/uuid.ts b/packages/ai/src/utils/uuid.ts similarity index 83% rename from packages/agent/src/harness/session/uuid.ts rename to packages/ai/src/utils/uuid.ts index 0c4cef8e0a5..53d900ca3e5 100644 --- a/packages/agent/src/harness/session/uuid.ts +++ b/packages/ai/src/utils/uuid.ts @@ -1,10 +1,9 @@ let lastTimestamp = -Infinity; let sequence = 0; -function fillRandomBytes(bytes: Uint8Array): void { - const crypto = globalThis.crypto; - if (crypto?.getRandomValues) { - crypto.getRandomValues(bytes); +function fillRandomBytes(bytes: Uint8Array): void { + if (globalThis.crypto?.getRandomValues) { + globalThis.crypto.getRandomValues(bytes); return; } for (let i = 0; i < bytes.length; i++) { @@ -12,6 +11,7 @@ function fillRandomBytes(bytes: Uint8Array): void { } } +/** Generate a time-ordered UUIDv7. */ export function uuidv7(): string { const random = new Uint8Array(16); fillRandomBytes(random); @@ -22,9 +22,7 @@ export function uuidv7(): string { lastTimestamp = timestamp; } else { sequence = (sequence + 1) >>> 0; - if (sequence === 0) { - lastTimestamp++; - } + if (sequence === 0) lastTimestamp++; } const bytes = new Uint8Array(16); @@ -45,10 +43,6 @@ export function uuidv7(): string { bytes[14] = random[14]; bytes[15] = random[15]; - return formatUuid(bytes); -} - -function formatUuid(bytes: Uint8Array): string { const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`; } diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index ce81422cbc0..df1904db965 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -249,8 +249,32 @@ describe("AI Providers Abort Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Abort", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Abort", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should abort mid-stream", { retry: 3 }, async () => { + await testAbortSignal(llm); + }); + + it("should handle immediate abort", { retry: 3 }, async () => { + await testImmediateAbort(llm); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Abort", () => { - const llm = getModel("kimi-coding", "k2p7"); + const llm = getModel("kimi-coding", "kimi-for-coding"); it("should abort mid-stream", { retry: 3 }, async () => { await testAbortSignal(llm); diff --git a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts index 7807ffc1769..329c8a7257f 100644 --- a/packages/ai/test/anthropic-adaptive-thinking-models.test.ts +++ b/packages/ai/test/anthropic-adaptive-thinking-models.test.ts @@ -7,7 +7,7 @@ const EXPECTED_CURRENT_ADAPTIVE_THINKING_MODELS = [ "anthropic/claude-opus-4-8", "anthropic/claude-sonnet-5", "cloudflare-ai-gateway/claude-fable-5", - "kimi-coding/k2p7", + "kimi-coding/kimi-for-coding", "kimi-coding/k3", "kimi-coding/kimi-for-coding-highspeed", "opencode/claude-opus-4-8", diff --git a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts index 0a922db7c50..ffaf48dbe22 100644 --- a/packages/ai/test/anthropic-force-adaptive-thinking.test.ts +++ b/packages/ai/test/anthropic-force-adaptive-thinking.test.ts @@ -90,7 +90,7 @@ describe("Anthropic forceAdaptiveThinking compat override", () => { }); it.each([ - ["k2p7", "medium", "medium"], + ["kimi-for-coding", "medium", "medium"], ["k3", "max", "max"], ["kimi-for-coding-highspeed", "medium", "medium"], ] as const)( diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index e5acb6f39ba..ab5fd9274bf 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -466,13 +466,37 @@ describe("Context overflow error handling", () => { }, 120000); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan", () => { + it("qwen3.7-max - should detect overflow via isContextOverflow", async () => { + const model = getModel("qwen-token-plan", "qwen3.7-max"); + const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/input length/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN)", () => { + it("qwen3.7-max - should detect overflow via isContextOverflow", async () => { + const model = getModel("qwen-token-plan-cn", "qwen3.7-max"); + const result = await testContextOverflow(model, process.env.QWEN_TOKEN_PLAN_CN_API_KEY!); + logResult(result); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/input length/i); + expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); + }, 120000); + }); + // ============================================================================= // Kimi For Coding // ============================================================================= describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding", () => { - it("k2p7 - should detect overflow via isContextOverflow", async () => { - const model = getModel("kimi-coding", "k2p7"); + it("kimi-for-coding - should detect overflow via isContextOverflow", async () => { + const model = getModel("kimi-coding", "kimi-for-coding"); const result = await testContextOverflow(model, process.env.KIMI_API_KEY!); logResult(result); diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index fe3b7adaef9..f815138608e 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -109,7 +109,7 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ // Together AI { provider: "together", model: "moonshotai/Kimi-K2.6", label: "together-kimi-k2.6" }, // Kimi For Coding - { provider: "kimi-coding", model: "k2p7", label: "kimi-coding-k2p7" }, + { provider: "kimi-coding", model: "kimi-for-coding", label: "kimi-for-coding" }, // Mistral { provider: "mistral", model: "devstral-medium-latest", label: "mistral-devstral-medium" }, // MiniMax @@ -130,6 +130,9 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ { provider: "xiaomi-token-plan-cn", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-cn-mimo-v2.5-pro" }, { provider: "xiaomi-token-plan-ams", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-ams-mimo-v2.5-pro" }, { provider: "xiaomi-token-plan-sgp", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-sgp-mimo-v2.5-pro" }, + // Qwen Token Plan + { provider: "qwen-token-plan", model: "qwen3.7-max", label: "qwen-token-plan-qwen3.7-max" }, + { provider: "qwen-token-plan-cn", model: "qwen3.7-max", label: "qwen-token-plan-cn-qwen3.7-max" }, ]; // Cached context structure diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 2f75f888be5..0a0705f516b 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -535,8 +535,48 @@ describe("AI Providers Empty Message Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Empty Messages", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Empty Messages", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { + await testEmptyMessage(llm); + }); + + it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { + await testEmptyStringMessage(llm); + }); + + it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { + await testWhitespaceOnlyMessage(llm); + }); + + it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { + await testEmptyAssistantMessage(llm); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Empty Messages", () => { - const llm = getModel("kimi-coding", "k2p7"); + const llm = getModel("kimi-coding", "kimi-for-coding"); it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { await testEmptyMessage(llm); diff --git a/packages/ai/test/image-model-data.test.ts b/packages/ai/test/image-model-data.test.ts new file mode 100644 index 00000000000..21d4680509e --- /dev/null +++ b/packages/ai/test/image-model-data.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { parseOpenRouterImageModels } from "../scripts/generate-image-models.ts"; + +const validImageModel = { + id: "example/image-model", + name: "Example Image Model", + architecture: { + input_modalities: ["text", "image"], + output_modalities: ["image"], + }, + pricing: { + prompt: "0.000001", + completion: "0.000002", + }, +}; + +describe("OpenRouter image model parsing", () => { + it.each([{}, { data: [] }, { data: "invalid" }])("rejects a missing or empty strict catalog", (payload) => { + expect(() => parseOpenRouterImageModels(payload, true)).toThrow("missing or empty image model list"); + }); + + it("rejects a strict catalog with no usable image models", () => { + expect(() => + parseOpenRouterImageModels( + { + data: [ + { + ...validImageModel, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + }, + ], + }, + true, + ), + ).toThrow("no usable image models"); + }); + + it("parses a non-empty image model catalog", () => { + expect(parseOpenRouterImageModels({ data: [validImageModel] }, true)).toEqual([ + expect.objectContaining({ + id: "example/image-model", + input: ["text", "image"], + output: ["image"], + }), + ]); + }); +}); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 7c96d6cc953..150f3d8b757 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -381,8 +381,32 @@ describe("Tool Results with Images", () => { }, ); - describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (k2p7)", () => { - const llm = getModel("kimi-coding", "k2p7"); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider (qwen3.7-max)", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider (qwen3.7-max)", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithImageResult(llm); + }); + + it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + await handleToolWithTextAndImageResult(llm); + }); + }); + + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (kimi-for-coding)", () => { + const llm = getModel("kimi-coding", "kimi-for-coding"); it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { await handleToolWithImageResult(llm); diff --git a/packages/ai/test/model-data-validation.test.ts b/packages/ai/test/model-data-validation.test.ts new file mode 100644 index 00000000000..1e7798d3f98 --- /dev/null +++ b/packages/ai/test/model-data-validation.test.ts @@ -0,0 +1,123 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createModelDataManifest, + MODEL_DATA_MANIFEST_FILE, + MODEL_DATA_SCHEMA_VERSION, + type ModelDataStructure, + readModelDataStructure, + validateModelDataDirectory, +} from "../scripts/model-data.ts"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +function createFixture(): { + dataDir: string; + packageRoot: string; + structure: ModelDataStructure; + values: Record; +} { + const packageRoot = mkdtempSync(join(tmpdir(), "pi-model-data-")); + temporaryRoots.push(packageRoot); + const providersDir = join(packageRoot, "src", "providers"); + const dataDir = join(providersDir, "data"); + mkdirSync(dataDir, { recursive: true }); + writeFileSync( + join(packageRoot, "src", "models.generated.ts"), + 'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\n\nexport const MODELS = {\n\t"test-provider": TEST_PROVIDER_MODELS,\n} as const;\n', + ); + writeFileSync( + join(providersDir, "test-provider.models.ts"), + '// generated\n\nimport values from "./data/test-provider.json" with { type: "json" };\nimport type { Model } from "../types.ts";\n\nexport const TEST_PROVIDER_MODELS = values as {\n\t"model-a": Model<"openai-completions"> & {\n\t\tid: "model-a";\n\t\tprovider: "test-provider";\n\t};\n};\n', + ); + + const structure = readModelDataStructure(packageRoot); + const values: Record = { + "model-a": { + id: "model-a", + name: "Model A", + api: "openai-completions", + provider: "test-provider", + baseUrl: "https://example.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }, + }; + writeFixtureData(dataDir, structure, values); + return { dataDir, packageRoot, structure, values }; +} + +function writeFixtureData( + dataDir: string, + structure: ModelDataStructure, + values: Record, + manifestSchemaVersion = MODEL_DATA_SCHEMA_VERSION, +): void { + const filename = "test-provider.json"; + const content = `${JSON.stringify(values)}\n`; + writeFileSync(join(dataDir, filename), content); + const manifest = createModelDataManifest(structure, { [filename]: content }); + manifest.schemaVersion = manifestSchemaVersion; + writeFileSync(join(dataDir, MODEL_DATA_MANIFEST_FILE), `${JSON.stringify(manifest)}\n`); +} + +describe("generated model data validation", () => { + it("validates complete data against generated structural catalogs", () => { + const { dataDir, structure } = createFixture(); + expect(() => validateModelDataDirectory(structure, dataDir)).not.toThrow(); + }); + + it("rejects a missing model data directory", () => { + const { dataDir, structure } = createFixture(); + rmSync(dataDir, { recursive: true }); + expect(() => validateModelDataDirectory(structure, dataDir)).toThrow("does not exist"); + }); + + it.each([ + ["id", "wrong-id", "has id"], + ["provider", "wrong-provider", "has provider"], + ["api", "anthropic-messages", "has api"], + ] as const)("rejects a wrong model %s", (field, value, expectedMessage) => { + const fixture = createFixture(); + const model = fixture.values["model-a"] as Record; + model[field] = value; + writeFixtureData(fixture.dataDir, fixture.structure, fixture.values); + expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(expectedMessage); + }); + + it("rejects missing model IDs and stale file hashes", () => { + const fixture = createFixture(); + writeFileSync(join(fixture.dataDir, "test-provider.json"), "{}\n"); + expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow(/manifest hash|model IDs/); + }); + + it("rejects incompatible schema and generation stamps", () => { + const fixture = createFixture(); + writeFixtureData(fixture.dataDir, fixture.structure, fixture.values, MODEL_DATA_SCHEMA_VERSION + 1); + expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("model data schema"); + + const manifestPath = join(fixture.dataDir, MODEL_DATA_MANIFEST_FILE); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + manifest.structureHash = "stale"; + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); + expect(() => validateModelDataDirectory(fixture.structure, fixture.dataDir)).toThrow("generation stamp"); + }); + + it("rejects missing provider shards referenced by the aggregator", () => { + const { packageRoot } = createFixture(); + writeFileSync( + join(packageRoot, "src", "models.generated.ts"), + 'import { TEST_PROVIDER_MODELS } from "./providers/test-provider.models.ts";\nimport { MISSING_MODELS } from "./providers/missing.models.ts";\n', + ); + expect(() => readModelDataStructure(packageRoot)).toThrow("aggregator and provider shards do not match"); + }); +}); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index b1f0777373c..a74849d8a0f 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -1088,6 +1088,18 @@ describe("openai-completions tool_choice", () => { } }); + it("stores Qwen Token Plan reasoning replay compat in built-in metadata", () => { + const providers = ["qwen-token-plan", "qwen-token-plan-cn"] as const; + + for (const provider of providers) { + const model = getModel(provider, "qwen3.7-max")!; + expect(model.compat?.thinkingFormat).toBe("qwen"); + expect(model.compat?.requiresReasoningContentOnAssistantMessages).toBeUndefined(); + expect(model.compat?.supportsDeveloperRole).toBe(false); + expect(model.compat?.supportsStore).toBe(false); + } + }); + it("replays Xiaomi MiMo assistant tool calls with empty reasoning_content when thinking is missing", async () => { const model = getModel("xiaomi", "mimo-v2.5-pro")!; const assistantMessage: AssistantMessage = { diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index fb2f67a3458..e034ebecde8 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -59,7 +59,6 @@ describe("builtin providers", () => { it("uses API-equivalent implied pricing for Kimi Coding subscription models", () => { const models = builtinModels(); const expectedCosts = { - k2p7: { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 }, k3: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 0 }, "kimi-for-coding-highspeed": { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 0 }, }; diff --git a/packages/ai/test/qwen-token-plan-models.test.ts b/packages/ai/test/qwen-token-plan-models.test.ts new file mode 100644 index 00000000000..ac5bbc6a976 --- /dev/null +++ b/packages/ai/test/qwen-token-plan-models.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { getModels } from "../src/compat.ts"; + +const TEXT_MODELS = [ + "MiniMax-M2.5", + "deepseek-v3.2", + "deepseek-v4-flash", + "deepseek-v4-pro", + "glm-5", + "glm-5.1", + "glm-5.2", + "kimi-k2.5", + "kimi-k2.6", + "kimi-k2.7-code", + "qwen3.6-flash", + "qwen3.6-plus", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.8-max-preview", +]; + +const IMAGE_MODELS = ["qwen-image-2.0", "qwen-image-2.0-pro", "wan2.7-image", "wan2.7-image-pro"]; + +describe("Qwen Token Plan models", () => { + it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)("exposes all text models on %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const expected of TEXT_MODELS) { + expect(modelIds, `${provider} should include ${expected}`).toContain(expected); + } + }); + + it.each(["qwen-token-plan", "qwen-token-plan-cn"] as const)("omits image models from %s", (provider) => { + const modelIds = getModels(provider).map((model) => model.id); + for (const excluded of IMAGE_MODELS) { + expect(modelIds, `${provider} should not include ${excluded}`).not.toContain(excluded); + } + }); +}); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 13a53c911c4..232df7a08de 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1017,29 +1017,32 @@ describe("Generate E2E Tests", () => { }); }); - describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider (k2p7 via Anthropic Messages)", () => { - const llm = getModel("kimi-coding", "k2p7"); + describe.skipIf(!process.env.KIMI_API_KEY)( + "Kimi For Coding Provider (kimi-for-coding via Anthropic Messages)", + () => { + const llm = getModel("kimi-coding", "kimi-for-coding"); - it("should complete basic text generation", { retry: 3 }, async () => { - await basicTextGeneration(llm); - }); + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); - it("should handle tool calling", { retry: 3 }, async () => { - await handleToolCall(llm); - }); + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); - it("should handle streaming", { retry: 3 }, async () => { - await handleStreaming(llm); - }); + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); - it("should handle thinking mode", { retry: 3 }, async () => { - await handleThinking(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); - }); + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); + }); - it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - await multiTurn(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); - }); - }); + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); + }); + }, + ); describe.skipIf(!process.env.XIAOMI_API_KEY)( "Xiaomi MiMo (API billing) Provider (Xiaomi MiMo-V2.5-Pro via Anthropic Messages)", @@ -1165,6 +1168,65 @@ describe("Generate E2E Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)( + "Qwen Token Plan Provider (Qwen3.7-Max, international)", + () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }, + ); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan Provider (Qwen3.7-Max, CN region)", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + const thinkingOptions = { + thinkingEnabled: true, + reasoningEffort: "high", + } satisfies StreamOptionsWithExtras; + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, thinkingOptions); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, thinkingOptions); + }); + }); + describe.skipIf(!process.env.ANT_LING_API_KEY)("Ant Ling Provider (Ling 2.6 Flash via OpenAI Completions)", () => { const llm = getModel("ant-ling", "Ling-2.6-flash"); diff --git a/packages/ai/test/supports-xhigh.test.ts b/packages/ai/test/supports-xhigh.test.ts index fa1eba91b0f..b6988ba06f6 100644 --- a/packages/ai/test/supports-xhigh.test.ts +++ b/packages/ai/test/supports-xhigh.test.ts @@ -110,10 +110,10 @@ describe("getSupportedThinkingLevels", () => { } }); - it("includes only max for Kimi Coding K3", () => { + it("includes only low, high, max for Kimi Coding K3", () => { const model = getModel("kimi-coding", "k3"); expect(model).toBeDefined(); - expect(getSupportedThinkingLevels(model!)).toEqual(["max"]); + expect(getSupportedThinkingLevels(model!)).toEqual(["low", "high", "max"]); }); it("includes only high for OpenCode Grok Build", () => { diff --git a/packages/ai/test/text.test.ts b/packages/ai/test/text.test.ts new file mode 100644 index 00000000000..43dc21c8463 --- /dev/null +++ b/packages/ai/test/text.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { type AssistantMessage, contentText, type ToolResultMessage } from "../src/index.ts"; + +const content: AssistantMessage["content"] = [ + { type: "thinking", thinking: "reasoning" }, + { type: "text", text: "first" }, + { type: "toolCall", id: "1", name: "read", arguments: {} }, + { type: "text", text: "second" }, +]; + +describe("contentText", () => { + it("extracts assistant text blocks", () => { + expect(contentText(content)).toBe("first\nsecond"); + }); + + it("supports custom separators", () => { + expect(contentText(content, "")).toBe("firstsecond"); + }); + + it("passes string content through", () => { + expect(contentText("hello")).toBe("hello"); + }); + + it("extracts text from tool-result content", () => { + const toolResultContent: ToolResultMessage["content"] = [ + { type: "text", text: "first" }, + { type: "image", data: "...", mimeType: "image/png" }, + { type: "text", text: "second" }, + ]; + + expect(contentText(toolResultContent, "")).toBe("firstsecond"); + }); +}); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 9422a86ecfb..665e900996a 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -218,7 +218,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider", () => { - const llm = getModel("kimi-coding", "k2p7"); + const llm = getModel("kimi-coding", "kimi-for-coding"); it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { await testTokensOnAbort(llm); @@ -276,6 +276,22 @@ describe("Token Statistics on Abort", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + // ========================================================================= // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) // ========================================================================= diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 22fb6949260..f089e7a8934 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -254,8 +254,24 @@ describe("Tool Call Without Result Tests", () => { }); }); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider", () => { + const model = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider", () => { + const model = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { + await testToolCallWithoutResult(model); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider", () => { - const model = getModel("kimi-coding", "k2p7"); + const model = getModel("kimi-coding", "kimi-for-coding"); it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { await testToolCallWithoutResult(model); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 93f06c1f709..00ba4ab2f4e 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -562,23 +562,77 @@ describe("totalTokens field", () => { ); }); + // ========================================================================= + // Qwen Token Plan + // ========================================================================= + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan", () => { + it( + "qwen3.7-max - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + console.log(`\nQwen Token Plan / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.QWEN_TOKEN_PLAN_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + + // ========================================================================= + // Qwen Token Plan CN + // ========================================================================= + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN)", () => { + it( + "qwen3.7-max - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + console.log(`\nQwen Token Plan CN / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { + apiKey: process.env.QWEN_TOKEN_PLAN_CN_API_KEY, + }); + + logUsage("First request", first); + logUsage("Second request", second); + + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); + }); + // ========================================================================= // Kimi For Coding // ========================================================================= describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding", () => { - it("k2p7 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { - const llm = getModel("kimi-coding", "k2p7"); + it( + "kimi-for-coding - should return totalTokens equal to sum of components", + { retry: 3, timeout: 60000 }, + async () => { + const llm = getModel("kimi-coding", "kimi-for-coding"); - console.log(`\nKimi For Coding / ${llm.id}:`); - const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.KIMI_API_KEY }); + console.log(`\nKimi For Coding / ${llm.id}:`); + const { first, second } = await testTotalTokensWithCache(llm, { apiKey: process.env.KIMI_API_KEY }); - logUsage("First request", first); - logUsage("Second request", second); + logUsage("First request", first); + logUsage("Second request", second); - assertTotalTokensEqualsComponents(first); - assertTotalTokensEqualsComponents(second); - }); + assertTotalTokensEqualsComponents(first); + assertTotalTokensEqualsComponents(second); + }, + ); }); // ========================================================================= diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index d37e5b0fb91..65ee509e920 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -704,8 +704,40 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }, ); + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Unicode Handling", () => { + const llm = getModel("qwen-token-plan", "qwen3.7-max"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { + await testUnpairedHighSurrogate(llm); + }); + }); + + describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Unicode Handling", () => { + const llm = getModel("qwen-token-plan-cn", "qwen3.7-max"); + + it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { + await testEmojiInToolResults(llm); + }); + + it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { + await testRealWorldLinkedInData(llm); + }); + + it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { + await testUnpairedHighSurrogate(llm); + }); + }); + describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Unicode Handling", () => { - const llm = getModel("kimi-coding", "k2p7"); + const llm = getModel("kimi-coding", "kimi-for-coding"); it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { await testEmojiInToolResults(llm); diff --git a/packages/agent/test/harness/session-uuid.test.ts b/packages/ai/test/uuid.test.ts similarity index 96% rename from packages/agent/test/harness/session-uuid.test.ts rename to packages/ai/test/uuid.test.ts index b8d571f4e6e..3e542650286 100644 --- a/packages/agent/test/harness/session-uuid.test.ts +++ b/packages/ai/test/uuid.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { uuidv7 } from "../../src/harness/session/uuid.ts"; +import { uuidv7 } from "../src/utils/uuid.ts"; const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const TIMESTAMP = 0x0123456789ab; diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0ab52bdc6b1..0bdc829a5a8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,11 +6,44 @@ - Added `ctx.queueCommand()` so extension tools and event handlers can queue real extension commands to run after the current agent turn with command-context capabilities. Commands that can invalidate later queued work can opt into terminal queue semantics. - Added exact branch-summary support to `ctx.navigateTree()` so extensions can attach a supplied summary without invoking model summarization. -- Added built-in llama.cpp router support with `/login` connection setup and `/llama` Hugging Face model search and downloads, explicit loading, unloading, and live progress. See [llama.cpp](docs/llama-cpp.md). -- Added extension registration for complete pi-ai providers, including native authentication, model refresh, filtering, and streaming behavior. -### Fixed +## [0.81.0] - 2026-07-21 + +### New Features +- **Local llama.cpp model management** — Connect to a llama.cpp router, search and download Hugging Face models, and explicitly load or unload models with live progress. See [llama.cpp](docs/llama-cpp.md). +- **Full provider extensions** — Extensions can register complete pi-ai providers with authentication, model refresh, filtering, and custom streaming. See [Register New Provider](docs/custom-provider.md#register-new-provider). +- **Qwen Token Plan providers** — Use the built-in international and China subscription providers with regional endpoints and API-key authentication. See [API Keys](docs/providers.md#api-keys). +- **Expanded usage accounting** — Tool, compaction, and branch-summary usage is persisted and included in session totals. See [Compaction & Branch Summarization](docs/compaction.md). + +### Added + +- Added Qwen Token Plan and Qwen Token Plan China to built-in provider setup, default model resolution, and provider documentation ([#6858](https://github.com/earendil-works/pi/pull/6858) by [@QuintinShaw](https://github.com/QuintinShaw)). +- Added the `get_available_thinking_levels` RPC command and `RpcClient.getAvailableThinkingLevels()` method ([#6865](https://github.com/earendil-works/pi/pull/6865) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Exported message and tool execution lifecycle event types from the package root ([#6772](https://github.com/earendil-works/pi/pull/6772) by [@davidbrai](https://github.com/davidbrai)). +- Added built-in llama.cpp router support with `/login` connection setup and `/llama` Hugging Face model search and downloads, explicit loading, unloading, and live progress. See [llama.cpp](docs/llama-cpp.md). +- Added extension registration for complete pi-ai providers, including native authentication, model refresh, filtering, and streaming behavior. +- Added usage accounting for tools, compaction, and branch summaries in persisted sessions, footer totals, and session statistics ([#6671](https://github.com/earendil-works/pi/pull/6671) by [@davidbrai](https://github.com/davidbrai)). + +### Fixed + +- Updated the packaged `brace-expansion` dependency to 5.0.7 ([#6896](https://github.com/earendil-works/pi/pull/6896) by [@davidbrai](https://github.com/davidbrai)). +- Fixed persisted remote model catalogs from overriding newer bundled catalogs after an upgrade. +- Fixed inherited stored API-key credentials to apply their provider-scoped `env` values, including Amazon Bedrock profiles ([#6864](https://github.com/earendil-works/pi/pull/6864) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Fixed inherited OpenAI-compatible cross-provider replay to keep tool call IDs unique when multiple calls share a provider call ID ([#6854](https://github.com/earendil-works/pi/pull/6854) by [@cristinaponcela](https://github.com/cristinaponcela)). +- Fixed inherited Kimi K3 thinking levels to expose low, high, and max, and normalized the `k2p7` alias to `kimi-for-coding`. +- Fixed inherited OpenCode Go models routed through the OpenAI Responses API. +- Fixed inherited `pi-ai` package metadata to avoid repeated consumer lockfile changes ([#6812](https://github.com/earendil-works/pi/pull/6812) by [@jmfederico](https://github.com/jmfederico)). +- Fixed inherited terminal shutdown to clear the editor's inverted software cursor before restoring the hardware cursor ([#6790](https://github.com/earendil-works/pi/pull/6790) by [@dam9000](https://github.com/dam9000)). +- Fixed inherited ANSI-aware text wrapping to recognize CRLF and CR line endings while preserving styles ([#6764](https://github.com/earendil-works/pi/pull/6764) by [@xz-dev](https://github.com/xz-dev)). +- Fixed inherited editor paste registry corruption after deleting and undoing paste markers, preventing literal or mismatched paste markers in submitted prompts ([#6844](https://github.com/earendil-works/pi/issues/6844)). +- Fixed sessionless OpenAI Codex WebSocket requests to use UUIDv7 request IDs ([#6834](https://github.com/earendil-works/pi/pull/6834) by [@xl0](https://github.com/xl0)). +- Fixed inherited GPT-5.6 Codex models to default to the 272K context window, avoiding automatic long-context pricing ([#6853](https://github.com/earendil-works/pi/pull/6853) by [@aadishv](https://github.com/aadishv)). +- Fixed messages queued during compaction to preserve steering and follow-up delivery behavior ([#6730](https://github.com/earendil-works/pi/pull/6730) by [@dannote](https://github.com/dannote)). +- Fixed read tool errors being syntax-highlighted as if they were file contents ([#6731](https://github.com/earendil-works/pi/pull/6731) by [@dannote](https://github.com/dannote)). +- Fixed llama.cpp router download progress updates and removed redundant wording from model action confirmations. +- Moved automatic model catalog network refresh out of startup initialization and into the running interactive and RPC modes. +- Fixed persisted sessions being read and parsed twice when opened, reducing startup latency for large sessions ([#6793](https://github.com/earendil-works/pi/issues/6793)). - Fixed prompt-template defaults for all arguments (`${@:-default}` and `${ARGUMENTS:-default}`) ([#6695](https://github.com/earendil-works/pi/issues/6695)). - Fixed obsolete custom UI, custom tool, and custom editor examples in the extension documentation ([#6735](https://github.com/earendil-works/pi/issues/6735)). - Fixed Kimi Coding sessions to show API-equivalent implied costs with the subscription indicator. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 3454fac2505..2198d7240a3 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -152,7 +152,7 @@ The interface from top to bottom: - **Startup header** - Shows shortcuts (`/hotkeys` for all), loaded AGENTS.md files, prompt templates, skills, and extensions - **Messages** - Your messages, assistant responses, tool calls and results, notifications, errors, and extension UI - **Editor** - Where you type; border color indicates thinking level -- **Footer** - Working directory, session name, total token/cache usage (`↑` input, `↓` output, `R` cache read, `W` cache write, `CH` latest cache hit rate), cost, context usage, current model +- **Footer** - Working directory, session name, total token/cache usage (`↑` input, `↓` output, `R` cache read, `W` cache write, `CH` latest cache hit rate), cost, context usage, current model. Totals include assistant responses, usage reported by tools, and summary generation. The editor can be temporarily replaced by other UI, like built-in `/settings` or custom UI from extensions (e.g., a Q&A tool that lets the user answer model questions in a structured format). [Extensions](#extensions) can also replace the editor, add widgets above/below it, a status line, custom footer, or overlays. diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index 5e0d4eefe26..d55c21364e3 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -129,6 +129,7 @@ interface CompactionEntry { summary: string; firstKeptEntryId: string; tokensBefore: number; + usage?: Usage; // LLM usage that generated the summary fromHook?: boolean; // true if provided by extension (legacy field name) details?: T; // implementation-specific data } @@ -140,9 +141,9 @@ interface CompactionDetails { } ``` -Extensions can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom extension implementations can use their own structure. +Extensions can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom extension implementations can use their own structure. Generated and extension-provided summaries store their LLM `usage` when available so session totals include summarization work. -See [`prepareCompaction()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) and [`compact()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) for the implementation. +See [`prepareCompaction()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) and [`compact()`](https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/src/core/compaction/compaction.ts) for the implementation. For direct programmatic summarization, `generateSummary()` returns the summary text and `generateSummaryWithUsage()` returns `{ text, usage }`. ## Branch Summarization @@ -195,6 +196,7 @@ interface BranchSummaryEntry { timestamp: number; summary: string; fromId: string; // Entry we navigated from + usage?: Usage; // LLM usage that generated the summary fromHook?: boolean; // true if provided by extension (legacy field name) details?: T; // implementation-specific data } @@ -300,6 +302,7 @@ pi.on("session_before_compact", async (event, ctx) => { summary: "Your summary...", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, + // usage: summaryResponse.usage, // Optional; included in session totals details: { /* custom data */ }, } }; @@ -328,13 +331,14 @@ pi.on("session_before_compact", async (event, ctx) => { // [Tool result]: output text // Now send to your model for summarization - const summary = await myModel.summarize(conversationText); + const { summary, usage } = await myModel.summarize(conversationText); return { compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, + usage, } }; }); @@ -364,6 +368,7 @@ pi.on("session_before_tree", async (event, ctx) => { return { summary: { summary: "Your summary...", + // usage: summaryResponse.usage, // Optional; included in session totals details: { /* custom data */ }, } }; diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 827326e2bad..e9978bf5a79 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -468,6 +468,7 @@ pi.on("session_before_compact", async (event, ctx) => { summary: "...", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, + // usage: summaryResponse.usage, // Optional; included in session totals } }; }); @@ -489,7 +490,13 @@ pi.on("session_before_tree", async (event, ctx) => { const { preparation, signal } = event; return { cancel: true }; // OR provide custom summary: - return { summary: { summary: "...", details: {} } }; + return { + summary: { + summary: "...", + // usage: summaryResponse.usage, // Optional; included in session totals + details: {}, + }, + }; }); pi.on("session_tree", async (event, ctx) => { @@ -813,7 +820,7 @@ In parallel tool mode, `tool_result` and `tool_execution_end` may interleave in `tool_result` handlers chain like middleware: - Handlers run in extension load order - Each handler sees the latest result after previous handler changes -- Handlers can return partial patches (`content`, `details`, or `isError`); omitted fields keep their current values +- Handlers can return partial patches (`content`, `details`, `isError`, or `usage`); omitted fields keep their current values Use `ctx.signal` for nested async work inside the handler. This lets Esc cancel model calls, `fetch()`, and other abort-aware operations started by the extension. @@ -822,7 +829,7 @@ import { isBashToolResult } from "@earendil-works/pi-coding-agent"; pi.on("tool_result", async (event, ctx) => { // event.toolName, event.toolCallId, event.input - // event.content, event.details, event.isError + // event.content, event.details, event.isError, event.usage if (isBashToolResult(event)) { // event.details is typed as BashToolDetails @@ -835,7 +842,7 @@ pi.on("tool_result", async (event, ctx) => { }); // Modify result: - return { content: [...], details: {...}, isError: false }; + return { content: [...], details: {...}, isError: false, usage: nestedModelUsage }; }); ``` @@ -1975,6 +1982,7 @@ pi.registerTool({ return { content: [{ type: "text", text: "Done" }], // Sent to LLM details: { data: result }, // For rendering & state + // usage: nestedModelResponse.usage, // Optional nested LLM usage // Optional: stop after this tool batch when every finalized tool result // in the batch also returns terminate: true. terminate: true, @@ -1987,6 +1995,8 @@ pi.registerTool({ }); ``` +**Usage accounting:** If a tool makes nested LLM calls, return their combined `Usage` as `usage`. Pi persists it on the tool result and includes it in footer, `/session`, and RPC session totals. `tool_result` handlers can inspect or replace this value. + **Signaling errors:** To mark a tool execution as failed (sets `isError: true` on the result and reports it to the LLM), throw an error from `execute`. Returning a value never sets the error flag regardless of what properties you include in the return object. **Early termination:** Return `terminate: true` from `execute()` to hint that the automatic follow-up LLM call should be skipped after the current tool batch. This only takes effect when every finalized tool result in that batch is terminating. See [examples/extensions/structured-output.ts](../examples/extensions/structured-output.ts) for a minimal example where the agent ends on a final structured-output tool call. diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index 9517dd0cba6..e8758a3d871 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -87,6 +87,8 @@ pi | Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` | | MiniMax | `MINIMAX_API_KEY` | `minimax` | | MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` | +| Qwen Token Plan | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` | +| Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | `qwen-token-plan-cn` | | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | | Xiaomi MiMo Token Plan (China) | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | `xiaomi-token-plan-cn` | | Xiaomi MiMo Token Plan (Amsterdam) | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | `xiaomi-token-plan-ams` | @@ -109,6 +111,8 @@ Store credentials in `~/.pi/agent/auth.json`: "opencode": { "type": "api_key", "key": "..." }, "opencode-go": { "type": "api_key", "key": "..." }, "together": { "type": "api_key", "key": "..." }, + "qwen-token-plan": { "type": "api_key", "key": "sk-sp-..." }, + "qwen-token-plan-cn": { "type": "api_key", "key": "sk-sp-..." }, "xiaomi": { "type": "api_key", "key": "..." }, "xiaomi-token-plan-cn": { "type": "api_key", "key": "..." }, "xiaomi-token-plan-ams": { "type": "api_key", "key": "..." }, diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 1493f2d55bb..2151b4b3bb5 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -313,6 +313,26 @@ Response: } ``` +#### get_available_thinking_levels + +List the thinking levels supported by the current model. Returns `["off"]` for a model without reasoning support. + +```json +{"type": "get_available_thinking_levels"} +``` + +Response: +```json +{ + "type": "response", + "command": "get_available_thinking_levels", + "success": true, + "data": { + "levels": ["off", "minimal", "low", "medium", "high"] + } +} +``` + ### Queue Modes #### set_steering_mode @@ -375,12 +395,20 @@ Response: "firstKeptEntryId": "abc123", "tokensBefore": 150000, "estimatedTokensAfter": 32000, + "usage": { + "input": 32000, + "output": 1200, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 33200, + "cost": {"input": 0.01, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.03} + }, "details": {} } } ``` -`estimatedTokensAfter` is a heuristic estimate over the rebuilt message context immediately after compaction, not a provider-exact token count. +`estimatedTokensAfter` is a heuristic estimate over the rebuilt message context immediately after compaction, not a provider-exact token count. `usage` reports the LLM call or calls that generated the summary and may be omitted by custom compaction handlers. #### set_auto_compaction @@ -537,7 +565,7 @@ Response: } ``` -`tokens` contains assistant usage totals for the current session state. `contextUsage` contains the actual current context-window estimate used for compaction and footer display. +`tokens` and `cost` include assistant messages, usage reported by tools, and compaction/branch-summary generation across the full session. `contextUsage` contains the actual current context-window estimate used for compaction and footer display. `contextUsage` is omitted when no model or context window is available. `contextUsage.tokens` and `contextUsage.percent` are `null` immediately after compaction until a fresh post-compaction assistant response provides valid usage data. @@ -996,6 +1024,14 @@ The `reason` field is `"manual"`, `"threshold"`, or `"overflow"`. "firstKeptEntryId": "abc123", "tokensBefore": 150000, "estimatedTokensAfter": 32000, + "usage": { + "input": 32000, + "output": 1200, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 33200, + "cost": {"input": 0.01, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.03} + }, "details": {} }, "aborted": false, @@ -1348,11 +1384,21 @@ Stop reasons: `"stop"`, `"length"`, `"toolUse"`, `"error"`, `"aborted"` "toolCallId": "call_123", "toolName": "bash", "content": [{"type": "text", "text": "total 48\ndrwxr-xr-x ..."}], + "usage": { + "input": 100, + "output": 50, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 150, + "cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105} + }, "isError": false, "timestamp": 1733234567890 } ``` +`usage` is optional and reports nested LLM work performed by the tool. When present, it contributes to session token and cost totals. + ### BashExecutionMessage Created by the `bash` RPC command (not by LLM tool calls): diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index a4afb318008..e2c49f1d05b 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -96,6 +96,7 @@ interface ToolResultMessage { toolName: string; content: (TextContent | ImageContent)[]; details?: any; // Tool-specific metadata + usage?: Usage; // Nested LLM work performed by the tool isError: boolean; timestamp: number; } @@ -231,9 +232,18 @@ Created when context is compacted. Stores a summary of earlier messages. {"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000} ``` +Newer harness-generated compactions embed the retained post-compaction context directly on the entry, instead of `firstKeptEntryId`: + +```json +{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","tokensBefore":50000,"retainedTail":[{"role":"user","content":"latest request"},{"role":"assistant","content":[{"type":"text","text":"latest reply"}],"provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop"}]} +``` + Optional fields: +- `usage`: LLM usage from generating the summary; included in session token and cost totals +- `retainedTail`: Materialized `AgentMessage[]` kept after compaction. This is optional only for backward compatibility with older sessions. Newer harness-generated compactions include it so we can rebuild context from this checkpoint without walking older entries before the compaction entry. - `details`: Implementation-specific data (e.g., `{ readFiles: string[], modifiedFiles: string[] }` for default, or custom data for extensions) - `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated (legacy field name) +- `firstKeptEntryId`: for compatibility with old entry format. ### BranchSummaryEntry @@ -244,6 +254,7 @@ Created when switching branches via `/tree` with an LLM generated summary of the ``` Optional fields: +- `usage`: LLM usage from generating the summary; included in session token and cost totals - `details`: File tracking data (`{ readFiles: string[], modifiedFiles: string[] }`) for default, or custom data for extensions - `fromHook`: `true` if generated by an extension, `false`/`undefined` if pi-generated (legacy field name) @@ -311,8 +322,9 @@ Entries form a tree: 1. Collects all entries on the path 2. If a `CompactionEntry` is on the path: - Includes the compaction entry first - - Then entries from `firstKeptEntryId` to compaction - - Then entries after compaction + - If `retainedTail` is present, it acts as a self-contained checkpoint and entries after the compaction are included + - Otherwise entries from `firstKeptEntryId` to the compaction are included + - Then entries after compaction are included 3. Preserves non-message entries in the selected range so interactive mode can render them `buildSessionContext()` builds on that entry list to produce the message list for the LLM: @@ -320,11 +332,13 @@ Entries form a tree: 1. Extracts current model and thinking level settings from the full path 2. Converts selected entries to messages: - `message` -> stored `AgentMessage` - - `compaction` -> `compactionSummary` + - `compaction` -> `compactionSummary` plus `retainedTail` when present - `branch_summary` -> `branchSummary` - `custom_message` -> `CustomMessage` - `custom` -> no context message +This makes newer compactions act like self-contained checkpoints. `retainedTail` is optional only so older sessions that only store `firstKeptEntryId` continue to load correctly. + ## Parsing Example ```typescript diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 48cf16b1730..c9df34efea2 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -11,7 +11,7 @@ The interface has four main areas: - **Startup header** - shortcuts, loaded context files, prompt templates, skills, and extensions - **Messages** - user messages, assistant responses, tool calls, tool results, notifications, errors, and extension UI - **Editor** - where you type; border color indicates the current thinking level -- **Footer** - working directory, session name, token/cache usage, cost, context usage, and current model +- **Footer** - working directory, session name, token/cache usage, cost, context usage, and current model. Totals include assistant responses, usage reported by tools, and summary generation. The editor can be replaced temporarily by built-in UI such as `/settings` or by custom extension UI. diff --git a/packages/coding-agent/examples/extensions/custom-compaction.ts b/packages/coding-agent/examples/extensions/custom-compaction.ts index fdc2128c6ac..e37ce976ad6 100644 --- a/packages/coding-agent/examples/extensions/custom-compaction.ts +++ b/packages/coding-agent/examples/extensions/custom-compaction.ts @@ -116,6 +116,7 @@ ${conversationText} summary, firstKeptEntryId, tokensBefore, + usage: response.usage, }, }; } catch (error) { diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index 2ccc8c48a3a..c35b6de935e 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,24 +1,24 @@ { - "name": "pi-extension-custom-provider", - "version": "0.80.10-kkl.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pi-extension-custom-provider", - "version": "0.80.10-kkl.1", - "dependencies": { - "@anthropic-ai/sdk": "^0.52.0" - } - }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.52.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", - "integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==", - "license": "MIT", - "bin": { - "anthropic-ai-sdk": "bin/cli" - } - } - } + "name": "pi-extension-custom-provider-anthropic", + "version": "0.81.0-kkl.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-extension-custom-provider-anthropic", + "version": "0.81.0-kkl.1", + "dependencies": { + "@anthropic-ai/sdk": "0.52.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", + "integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + } + } } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index d24120b091f..0c9ca725c3a 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 93c0aebac5e..f969c9c17a2 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index 26dbb000e21..40b50905dfc 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index 6fafd5685c1..2e7ed8158ed 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index e1b46ed475d..5cc2fb33cbf 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,14 +1,14 @@ { "name": "pi-extension-sandbox", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "dependencies": { - "@anthropic-ai/sandbox-runtime": "^0.0.26" + "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "node_modules/@anthropic-ai/sandbox-runtime": { diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index e2ca0a2f474..d78a0360427 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index 88365f46a0c..c4186bd98e0 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,31 +1,31 @@ { - "name": "pi-extension-with-deps", - "version": "0.80.10-kkl.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pi-extension-with-deps", - "version": "0.80.10-kkl.1", - "dependencies": { - "ms": "^2.1.3" - }, - "devDependencies": { - "@types/ms": "^2.1.0" - } - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - } - } + "name": "pi-extension-with-deps", + "version": "0.81.0-kkl.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-extension-with-deps", + "version": "0.81.0-kkl.1", + "dependencies": { + "ms": "2.1.3" + }, + "devDependencies": { + "@types/ms": "2.1.0" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + } + } } diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index b7f2c963cd1..bc90df7a37e 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 5a3a646fa0a..53a4477f824 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.10-kkl.1" + "@earendil-works/pi-coding-agent": "0.81.0-kkl.1" }, "engines": { "node": ">=22.19.0" @@ -450,11 +450,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.10-kkl.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10-kkl.1.tgz", + "version": "0.81.0-kkl.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0-kkl.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "0.80.10-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -464,8 +464,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.10-kkl.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10-kkl.1.tgz", + "version": "0.81.0-kkl.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0-kkl.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -481,20 +481,20 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.10-kkl.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.10-kkl.1.tgz", + "version": "0.81.0-kkl.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.81.0-kkl.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "0.80.10-kkl.1", - "@earendil-works/pi-ai": "0.80.10-kkl.1", - "@earendil-works/pi-tui": "0.80.10-kkl.1", + "@earendil-works/pi-agent-core": "0.81.0-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1", + "@earendil-works/pi-tui": "0.81.0-kkl.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -522,8 +522,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.10-kkl.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10-kkl.1.tgz", + "version": "0.81.0-kkl.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0-kkl.1.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -1033,9 +1033,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1356,8 +1356,7 @@ "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" - }, - "peer": true + } }, "node_modules/json-bigint": { "version": "1.0.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index 758bac3ba07..9d378b3e1a2 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.10-kkl.1" + "@earendil-works/pi-coding-agent": "0.81.0-kkl.1" }, "overrides": { "rimraf": "6.1.2", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 2167d51b4d5..885361ee35e 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "0.80.10-kkl.1", - "@earendil-works/pi-ai": "0.80.10-kkl.1", - "@earendil-works/pi-tui": "0.80.10-kkl.1", + "@earendil-works/pi-agent-core": "0.81.0-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1", + "@earendil-works/pi-tui": "0.81.0-kkl.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.80.10-kkl.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.10-kkl.1.tgz", + "version": "0.81.0-kkl.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.81.0-kkl.1.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "0.80.10-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.10-kkl.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.10-kkl.1.tgz", + "version": "0.81.0-kkl.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.81.0-kkl.1.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -505,15 +505,15 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "./dist/cli.js" + "pi-ai": "dist/cli.js" }, "engines": { "node": ">=22.19.0" } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.10-kkl.1", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.10-kkl.1.tgz", + "version": "0.81.0-kkl.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.81.0-kkl.1.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -1023,9 +1023,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1346,8 +1346,7 @@ "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" - }, - "peer": true + } }, "node_modules/json-bigint": { "version": "1.0.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 36e2115ff46..96bc58ba85f 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.10-kkl.1", + "version": "0.81.0-kkl.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.80.10-kkl.1", - "@earendil-works/pi-ai": "0.80.10-kkl.1", - "@earendil-works/pi-tui": "0.80.10-kkl.1", + "@earendil-works/pi-agent-core": "0.81.0-kkl.1", + "@earendil-works/pi-ai": "0.81.0-kkl.1", + "@earendil-works/pi-tui": "0.81.0-kkl.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 0ca339694fd..c194579c57d 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -362,6 +362,8 @@ ${chalk.bold("Environment Variables:")} CLOUDFLARE_API_KEY - Cloudflare API token (Workers AI and AI Gateway) CLOUDFLARE_ACCOUNT_ID - Cloudflare account id (required for both) CLOUDFLARE_GATEWAY_ID - Cloudflare AI Gateway slug (required for AI Gateway) + QWEN_TOKEN_PLAN_API_KEY - Qwen Token Plan API key (international region) + QWEN_TOKEN_PLAN_CN_API_KEY - Qwen Token Plan API key (China region) XIAOMI_API_KEY - Xiaomi MiMo API key (api.xiaomimimo.com billing) XIAOMI_TOKEN_PLAN_CN_API_KEY - Xiaomi MiMo Token Plan API key (China region) XIAOMI_TOKEN_PLAN_AMS_API_KEY - Xiaomi MiMo Token Plan API key (Amsterdam region) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 6d0417b2162..ce304e3f2ec 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -24,14 +24,15 @@ import type { PrepareNextTurnContext, ThinkingLevel, } from "@earendil-works/pi-agent-core"; +import { contentText } from "@earendil-works/pi-ai"; import type { AssistantMessage, AuthResult, ImageContent, - Message, Model, ProviderHeaders, TextContent, + Usage, } from "@earendil-works/pi-ai/compat"; import { clampThinkingLevel, @@ -106,6 +107,7 @@ import { type BuildSystemPromptOptions, buildSystemPrompt } from "./system-promp import { type BashOperations, createLocalBashOperations } from "./tools/bash.ts"; import { createAllToolDefinitions } from "./tools/index.ts"; import { createToolDefinitionFromAgentTool } from "./tools/tool-definition-wrapper.ts"; +import { addUsageToTotals, createUsageTotals } from "./usage-totals.ts"; // ============================================================================ // Skill Block Parsing @@ -440,7 +442,7 @@ export class AgentSession { headers?: Record; env?: Record; }> { - if (this.agent.streamFn === streamSimple) { + if (this.agent.streamFunction === streamSimple) { return this._getRequiredRequestAuth(model); } @@ -498,6 +500,7 @@ export class AgentSession { content: result.content, details: result.details, isError, + usage: result.usage, }); if (!hookResult) { @@ -508,6 +511,7 @@ export class AgentSession { content: hookResult.content, details: hookResult.details, isError: hookResult.isError ?? isError, + usage: hookResult.usage, }; }; } @@ -592,7 +596,7 @@ export class AgentSession { // This ensures the UI sees the updated queue state if (event.type === "message_start" && event.message.role === "user") { this._overflowRecoveryAttempted = false; - const messageText = this._getUserMessageText(event.message); + const messageText = contentText(event.message.content, ""); if (messageText) { // Check steering queue first const steeringIndex = this._steeringMessages.indexOf(messageText); @@ -675,15 +679,6 @@ export class AgentSession { return false; } - /** Extract text content from a message */ - private _getUserMessageText(message: Message): string { - if (message.role !== "user") return ""; - const content = message.content; - if (typeof content === "string") return content; - const textBlocks = content.filter((c) => c.type === "text"); - return textBlocks.map((c) => (c as TextContent).text).join(""); - } - /** Find the last assistant message in agent state (including aborted ones) */ private _findLastAssistantMessage(): AssistantMessage | undefined { const messages = this.agent.state.messages; @@ -1925,6 +1920,7 @@ export class AgentSession { let summary: string; let firstKeptEntryId: string; let tokensBefore: number; + let usage: Usage | undefined; let details: unknown; if (extensionCompaction) { @@ -1932,6 +1928,7 @@ export class AgentSession { summary = extensionCompaction.summary; firstKeptEntryId = extensionCompaction.firstKeptEntryId; tokensBefore = extensionCompaction.tokensBefore; + usage = extensionCompaction.usage; details = extensionCompaction.details; } else { // Generate compaction result @@ -1943,12 +1940,13 @@ export class AgentSession { customInstructions, this._compactionAbortController.signal, this.thinkingLevel, - this.agent.streamFn, + this.agent.streamFunction, env, ); summary = result.summary; firstKeptEntryId = result.firstKeptEntryId; tokensBefore = result.tokensBefore; + usage = result.usage; details = result.details; } @@ -1956,7 +1954,7 @@ export class AgentSession { throw new Error("Compaction cancelled"); } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; @@ -1982,6 +1980,7 @@ export class AgentSession { firstKeptEntryId, tokensBefore, estimatedTokensAfter, + usage, details, }; this._emit({ @@ -2142,7 +2141,7 @@ export class AgentSession { let apiKey: string | undefined; let headers: Record | undefined; let env: Record | undefined; - if (this.agent.streamFn === streamSimple) { + if (this.agent.streamFunction === streamSimple) { const authResult = await this._modelRuntime.getAuth(this.model); if (!authResult?.auth.apiKey) return false; apiKey = authResult.auth.apiKey; @@ -2197,6 +2196,7 @@ export class AgentSession { let summary: string; let firstKeptEntryId: string; let tokensBefore: number; + let usage: Usage | undefined; let details: unknown; if (extensionCompaction) { @@ -2204,6 +2204,7 @@ export class AgentSession { summary = extensionCompaction.summary; firstKeptEntryId = extensionCompaction.firstKeptEntryId; tokensBefore = extensionCompaction.tokensBefore; + usage = extensionCompaction.usage; details = extensionCompaction.details; } else { // Generate compaction result @@ -2215,12 +2216,13 @@ export class AgentSession { undefined, this._autoCompactionAbortController.signal, this.thinkingLevel, - this.agent.streamFn, + this.agent.streamFunction, env, ); summary = compactResult.summary; firstKeptEntryId = compactResult.firstKeptEntryId; tokensBefore = compactResult.tokensBefore; + usage = compactResult.usage; details = compactResult.details; } @@ -2235,7 +2237,7 @@ export class AgentSession { return false; } - this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension); + this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage); const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; @@ -2261,6 +2263,7 @@ export class AgentSession { firstKeptEntryId, tokensBefore, estimatedTokensAfter, + usage, details, }; this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry }); @@ -3006,7 +3009,7 @@ export class AgentSession { this._branchSummaryAbortController = new AbortController(); try { - let extensionSummary: { summary: string; details?: unknown } | undefined; + let extensionSummary: { summary: string; details?: unknown; usage?: Usage } | undefined; let fromExtension = exactSummary !== undefined && options.fromExtension === true; // Emit session_before_tree event @@ -3041,6 +3044,7 @@ export class AgentSession { // Run default summarizer if needed let summaryText: string | undefined; let summaryDetails: unknown; + let summaryUsage: Usage | undefined; if (exactSummary) { summaryText = exactSummary.summary; summaryDetails = exactSummary.details; @@ -3057,7 +3061,7 @@ export class AgentSession { customInstructions, replaceInstructions, reserveTokens: branchSummarySettings.reserveTokens, - streamFn: this.agent.streamFn, + streamFn: this.agent.streamFunction, }); if (result.aborted) { return { cancelled: true, aborted: true }; @@ -3066,6 +3070,7 @@ export class AgentSession { throw new Error(result.error); } summaryText = result.summary; + summaryUsage = result.usage; summaryDetails = { readFiles: result.readFiles || [], modifiedFiles: result.modifiedFiles || [], @@ -3073,6 +3078,7 @@ export class AgentSession { } else if (extensionSummary) { summaryText = extensionSummary.summary; summaryDetails = extensionSummary.details; + summaryUsage = extensionSummary.usage; } // Determine the new leaf position based on target type @@ -3082,17 +3088,11 @@ export class AgentSession { if (targetEntry.type === "message" && targetEntry.message.role === "user") { // User message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; - editorText = this._extractUserMessageText(targetEntry.message.content); + editorText = contentText(targetEntry.message.content, ""); } else if (targetEntry.type === "custom_message") { // Custom message: leaf = parent (null if root), text goes to editor newLeafId = targetEntry.parentId; - editorText = - typeof targetEntry.content === "string" - ? targetEntry.content - : targetEntry.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + editorText = contentText(targetEntry.content, ""); } else { // Non-user message: leaf = selected node newLeafId = targetId; @@ -3108,6 +3108,7 @@ export class AgentSession { summaryText, summaryDetails, fromExtension, + summaryUsage, ); summaryEntry = this.sessionManager.getEntry(summaryId) as BranchSummaryEntry; @@ -3160,7 +3161,7 @@ export class AgentSession { if (entry.type !== "message") continue; if (entry.message.role !== "user") continue; - const text = this._extractUserMessageText(entry.message.content); + const text = contentText(entry.message.content, ""); if (text) { result.push({ entryId: entry.id, text }); } @@ -3169,17 +3170,6 @@ export class AgentSession { return result; } - private _extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); - } - return ""; - } - /** * Get session statistics. Aggregates over ALL session entries (including * history that was compacted away), so token/cost totals reflect what was @@ -3191,13 +3181,12 @@ export class AgentSession { let toolResults = 0; let totalMessages = 0; let toolCalls = 0; - let totalInput = 0; - let totalOutput = 0; - let totalCacheRead = 0; - let totalCacheWrite = 0; - let totalCost = 0; + const usageTotals = createUsageTotals(); for (const entry of this.sessionManager.getEntries()) { + if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { + addUsageToTotals(usageTotals, entry.usage); + } if (entry.type !== "message") continue; totalMessages++; const message = entry.message; @@ -3205,18 +3194,16 @@ export class AgentSession { userMessages++; } else if (message.role === "toolResult") { toolResults++; + if (message.usage) { + addUsageToTotals(usageTotals, message.usage); + } } else if (message.role === "assistant") { assistantMessages++; const assistantMsg = message as AssistantMessage; if (Array.isArray(assistantMsg.content)) { toolCalls += assistantMsg.content.filter((c) => c.type === "toolCall").length; } - const usage = assistantMsg.usage; - totalInput += usage.input; - totalOutput += usage.output; - totalCacheRead += usage.cacheRead; - totalCacheWrite += usage.cacheWrite; - totalCost += usage.cost.total; + addUsageToTotals(usageTotals, assistantMsg.usage); } } @@ -3229,13 +3216,13 @@ export class AgentSession { toolResults, totalMessages, tokens: { - input: totalInput, - output: totalOutput, - cacheRead: totalCacheRead, - cacheWrite: totalCacheWrite, - total: totalInput + totalOutput + totalCacheRead + totalCacheWrite, + input: usageTotals.input, + output: usageTotals.output, + cacheRead: usageTotals.cacheRead, + cacheWrite: usageTotals.cacheWrite, + total: usageTotals.input + usageTotals.output + usageTotals.cacheRead + usageTotals.cacheWrite, }, - cost: totalCost, + cost: usageTotals.cost, contextUsage: this.getContextUsage(), }; } diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts index a96bb202d84..3366f06a7f5 100644 --- a/packages/coding-agent/src/core/compaction/branch-summarization.ts +++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts @@ -6,7 +6,8 @@ */ import type { AgentMessage, StreamFn } from "@earendil-works/pi-agent-core"; -import type { Model, SimpleStreamOptions } from "@earendil-works/pi-ai/compat"; +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, @@ -32,6 +33,7 @@ import { export interface BranchSummaryResult { summary?: string; + usage?: Usage; readFiles?: string[]; modifiedFiles?: string[]; aborted?: boolean; @@ -351,10 +353,7 @@ export async function generateBranchSummary( return { error: response.errorMessage || "Summarization failed" }; } - let summary = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + let summary = contentText(response.content); // Prepend preamble to provide context about the branch summary summary = BRANCH_SUMMARY_PREAMBLE + summary; @@ -365,6 +364,7 @@ export async function generateBranchSummary( return { summary: summary || "No summary generated", + usage: response.usage, readFiles, modifiedFiles, }; diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts index 39b1042d364..ffad75e8542 100644 --- a/packages/coding-agent/src/core/compaction/compaction.ts +++ b/packages/coding-agent/src/core/compaction/compaction.ts @@ -6,6 +6,7 @@ */ import type { AgentMessage, StreamFn, ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { contentText } 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"; @@ -89,10 +90,35 @@ export interface CompactionResult { firstKeptEntryId: string; tokensBefore: number; estimatedTokensAfter?: number; + /** Usage from the LLM call(s) that generated this summary, if available */ + usage?: Usage; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; } +function combineUsage(first: Usage, second: Usage): Usage { + return { + input: first.input + second.input, + output: first.output + second.output, + cacheRead: first.cacheRead + second.cacheRead, + cacheWrite: first.cacheWrite + second.cacheWrite, + ...(first.cacheWrite1h !== undefined || second.cacheWrite1h !== undefined + ? { cacheWrite1h: (first.cacheWrite1h ?? 0) + (second.cacheWrite1h ?? 0) } + : {}), + ...(first.reasoning !== undefined || second.reasoning !== undefined + ? { reasoning: (first.reasoning ?? 0) + (second.reasoning ?? 0) } + : {}), + totalTokens: first.totalTokens + second.totalTokens, + cost: { + input: first.cost.input + second.cost.input, + output: first.cost.output + second.cost.output, + cacheRead: first.cost.cacheRead + second.cost.cacheRead, + cacheWrite: first.cost.cacheWrite + second.cost.cacheWrite, + total: first.cost.total + second.cost.total, + }, + }; +} + // ============================================================================ // Types // ============================================================================ @@ -556,6 +582,37 @@ export async function generateSummary( streamFn?: StreamFn, env?: Record, ): Promise { + return ( + await generateSummaryWithUsage( + currentMessages, + model, + reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + streamFn, + env, + ) + ).text; +} + +/** Generate or update a conversation summary and return its provider usage. */ +export async function generateSummaryWithUsage( + currentMessages: AgentMessage[], + model: Model, + reserveTokens: number, + apiKey: string | undefined, + headers?: Record, + signal?: AbortSignal, + customInstructions?: string, + previousSummary?: string, + thinkingLevel?: ThinkingLevel, + streamFn?: StreamFn, + env?: Record, +): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.8 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, @@ -600,12 +657,9 @@ export async function generateSummary( throw new Error(`Summarization failed: ${response.errorMessage || "Unknown error"}`); } - const textContent = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + const textContent = contentText(response.content); - return textContent; + return { text: textContent, usage: response.usage }; } // ============================================================================ @@ -761,24 +815,28 @@ export async function compact( // Generate summaries and merge into one let summary: string; + let summaryUsage: Usage; if (isSplitTurn && turnPrefixMessages.length > 0) { - const historyResult = - messagesToSummarize.length > 0 - ? await generateSummary( - messagesToSummarize, - model, - settings.reserveTokens, - apiKey, - headers, - signal, - customInstructions, - previousSummary, - thinkingLevel, - streamFn, - env, - ) - : "No prior history."; + let historyText = "No prior history."; + let historyUsage: Usage | undefined; + if (messagesToSummarize.length > 0) { + const historyResult = await generateSummaryWithUsage( + messagesToSummarize, + model, + settings.reserveTokens, + apiKey, + headers, + signal, + customInstructions, + previousSummary, + thinkingLevel, + streamFn, + env, + ); + historyText = historyResult.text; + historyUsage = historyResult.usage; + } const turnPrefixResult = await generateTurnPrefixSummary( turnPrefixMessages, model, @@ -791,10 +849,11 @@ export async function compact( streamFn, ); // Merge into single summary - summary = `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult}`; + summary = `${historyText}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.text}`; + summaryUsage = historyUsage ? combineUsage(historyUsage, turnPrefixResult.usage) : turnPrefixResult.usage; } else { // Just generate history summary - summary = await generateSummary( + const result = await generateSummaryWithUsage( messagesToSummarize, model, settings.reserveTokens, @@ -807,6 +866,8 @@ export async function compact( streamFn, env, ); + summary = result.text; + summaryUsage = result.usage; } // Compute file lists and append to summary @@ -821,6 +882,7 @@ export async function compact( summary, firstKeptEntryId, tokensBefore, + usage: summaryUsage, details: { readFiles, modifiedFiles } as CompactionDetails, }; } @@ -838,7 +900,7 @@ async function generateTurnPrefixSummary( signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, -): Promise { +): Promise<{ text: string; usage: Usage }> { const maxTokens = Math.min( Math.floor(0.5 * reserveTokens), model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY, @@ -865,8 +927,8 @@ async function generateTurnPrefixSummary( throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`); } - return response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n"); + return { + text: contentText(response.content), + usage: response.usage, + }; } diff --git a/packages/coding-agent/src/core/compaction/utils.ts b/packages/coding-agent/src/core/compaction/utils.ts index 6cfc162279e..35d1a29bb0a 100644 --- a/packages/coding-agent/src/core/compaction/utils.ts +++ b/packages/coding-agent/src/core/compaction/utils.ts @@ -3,7 +3,7 @@ */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { Message } from "@earendil-works/pi-ai"; +import { contentText, type Message } from "@earendil-works/pi-ai"; // ============================================================================ // File Operation Tracking @@ -111,23 +111,14 @@ export function serializeConversation(messages: Message[]): string { for (const msg of messages) { if (msg.role === "user") { - const content = - typeof msg.content === "string" - ? msg.content - : msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + const content = contentText(msg.content, ""); if (content) parts.push(`[User]: ${content}`); } else if (msg.role === "assistant") { - const textParts: string[] = []; const thinkingParts: string[] = []; const toolCalls: string[] = []; for (const block of msg.content) { - if (block.type === "text") { - textParts.push(block.text); - } else if (block.type === "thinking") { + if (block.type === "thinking") { thinkingParts.push(block.thinking); } else if (block.type === "toolCall") { const args = block.arguments as Record; @@ -141,17 +132,14 @@ export function serializeConversation(messages: Message[]): string { if (thinkingParts.length > 0) { parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`); } - if (textParts.length > 0) { - parts.push(`[Assistant]: ${textParts.join("\n")}`); + if (msg.content.some((block) => block.type === "text")) { + parts.push(`[Assistant]: ${contentText(msg.content)}`); } if (toolCalls.length > 0) { parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`); } } else if (msg.role === "toolResult") { - const content = msg.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join(""); + const content = contentText(msg.content, ""); if (content) { parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`); } diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 34f65d84e04..f5360455a73 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -898,6 +898,10 @@ export class ExtensionRunner { currentEvent.isError = handlerResult.isError; modified = true; } + if (handlerResult.usage !== undefined) { + currentEvent.usage = handlerResult.usage; + modified = true; + } } catch (err) { const message = err instanceof Error ? err.message : String(err); const stack = err instanceof Error ? err.stack : undefined; @@ -919,6 +923,7 @@ export class ExtensionRunner { content: currentEvent.content, details: currentEvent.details, isError: currentEvent.isError, + usage: currentEvent.usage, }; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 8af230eec78..7524c43baba 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -31,6 +31,7 @@ import type { SimpleStreamOptions, TextContent, ToolResultMessage, + Usage, } from "@earendil-works/pi-ai"; import type { AutocompleteItem, @@ -932,6 +933,8 @@ interface ToolResultEventBase { input: Record; content: (TextContent | ImageContent)[]; isError: boolean; + /** Usage from the tool execution itself, if available. */ + usage?: Usage; } export interface BashToolResultEvent extends ToolResultEventBase { @@ -1099,6 +1102,7 @@ export interface ToolResultEventResult { content?: (TextContent | ImageContent)[]; details?: unknown; isError?: boolean; + usage?: Usage; } export interface MessageEndEventResult { @@ -1131,6 +1135,7 @@ export interface SessionBeforeTreeResult { summary?: { summary: string; details?: unknown; + usage?: Usage; }; /** Override custom instructions for summarization */ customInstructions?: string; diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 1f4dceaf3c9..bcf668e5655 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -44,6 +44,8 @@ export const defaultModelPerProvider: Record = { "kimi-coding": "kimi-for-coding", "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6", "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", + "qwen-token-plan": "qwen3.7-max", + "qwen-token-plan-cn": "qwen3.7-max", xiaomi: "mimo-v2.5-pro", "xiaomi-token-plan-cn": "mimo-v2.5-pro", "xiaomi-token-plan-ams": "mimo-v2.5-pro", diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index 2235d400627..2cd85b8d69e 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -62,7 +62,9 @@ export interface CreateModelRuntimeOptions { modelsPath?: string | null; modelsStore?: ModelsStore; modelsStorePath?: string; + /** Allow create() to refresh model catalogs over the network. Defaults to false. */ allowModelNetwork?: boolean; + /** Timeout for the create-time network model refresh. */ modelRefreshTimeoutMs?: number; catalogBaseUrl?: string; } @@ -98,7 +100,7 @@ export class ModelRuntime implements Models { private readonly extensionProviders = new Map(); private readonly compositionErrors = new Map(); private readonly modelsPath: string | undefined; - private readonly allowModelNetwork: boolean; + private readonly modelNetworkEnabled: boolean; private config: ModelConfig; private snapshot: ModelRuntimeSnapshot = { all: [], @@ -116,12 +118,12 @@ export class ModelRuntime implements Models { modelsPath: string | undefined, modelsStore: ModelsStore, providers: readonly Provider[], - allowModelNetwork: boolean, + modelNetworkEnabled: boolean, ) { this.credentials = credentials; this.config = config; this.modelsPath = modelsPath; - this.allowModelNetwork = allowModelNetwork; + this.modelNetworkEnabled = modelNetworkEnabled; this.defaultBuiltins = new Map(providers.map((provider) => [provider.id, provider])); for (const [providerId, provider] of this.defaultBuiltins) this.builtins.set(providerId, provider); this.models = createModels({ credentials, modelsStore }); @@ -141,7 +143,15 @@ export class ModelRuntime implements Models { const providers = builtinProviderCatalog .builtinProviders() .map((provider) => - provider.id === "radius" ? provider : withRemoteCatalog(provider, options.catalogBaseUrl), + provider.id === "radius" + ? provider + : withRemoteCatalog( + provider, + options.catalogBaseUrl, + builtinProviderCatalog.getBuiltinModelDataUrl( + provider.id as builtinProviderCatalog.BuiltinProvider, + ), + ), ); const runtime = new ModelRuntime( credentials, @@ -149,16 +159,17 @@ export class ModelRuntime implements Models { modelsPath, modelsStore, providers, - options.allowModelNetwork ?? process.env.PI_OFFLINE === undefined, + process.env.PI_OFFLINE === undefined, ); runtime.configureRadiusProviders(); runtime.rebuildProviders(); - const controller = new AbortController(); - const timeout = runtime.allowModelNetwork + const refreshFromNetwork = runtime.modelNetworkEnabled && options.allowModelNetwork === true; + const controller = refreshFromNetwork ? new AbortController() : undefined; + const timeout = controller ? setTimeout(() => controller.abort(), options.modelRefreshTimeoutMs ?? 15_000) : undefined; try { - await runtime.refresh({ allowNetwork: runtime.allowModelNetwork, signal: controller.signal }); + await runtime.refresh({ allowNetwork: refreshFromNetwork, signal: controller?.signal }); } finally { if (timeout) clearTimeout(timeout); } @@ -389,7 +400,11 @@ export class ModelRuntime implements Models { }; } - async setRuntimeApiKey(providerId: string, apiKey: string): Promise { + async setRuntimeApiKey( + providerId: string, + apiKey: string, + refreshOptions: ModelsRefreshOptions = {}, + ): Promise { this.credentials.setRuntimeApiKey(providerId, apiKey); const auth = new Map(this.snapshot.auth).set(providerId, { type: "api_key", source: "runtime API key" }); const configuredProviders = new Set(this.snapshot.configuredProviders).add(providerId); @@ -401,12 +416,12 @@ export class ModelRuntime implements Models { storedProviders, available: this.snapshot.all.filter((model) => configuredProviders.has(model.provider)), }; - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh(refreshOptions); } async removeRuntimeApiKey(providerId: string): Promise { this.credentials.removeRuntimeApiKey(providerId); - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh({ allowNetwork: this.modelNetworkEnabled }); } listCredentials(): Promise { @@ -492,7 +507,7 @@ export class ModelRuntime implements Models { async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise { const credential = await this.models.login(providerId, type, interaction); - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh({ allowNetwork: this.modelNetworkEnabled }); return credential; } @@ -500,20 +515,20 @@ export class ModelRuntime implements Models { await this.models.logout(providerId); // Reset credential-dependent compatibility projections before the unconfigured provider is skipped by refresh. this.recomposeProvider(providerId); - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh({ allowNetwork: this.modelNetworkEnabled }); } async reloadConfig(): Promise { this.config = await ModelConfig.load(this.modelsPath); this.configureRadiusProviders(); this.rebuildProviders(); - await this.refresh({ allowNetwork: this.allowModelNetwork }); + await this.refresh({ allowNetwork: this.modelNetworkEnabled }); } async refresh(options: ModelsRefreshOptions = {}): Promise { const refreshOptions = { ...options, - allowNetwork: options.allowNetwork ?? this.allowModelNetwork, + allowNetwork: options.allowNetwork ?? this.modelNetworkEnabled, }; // Published pi-ai builds before ModelsStore returned void and accepted a provider ID. // The fallback keeps source-mode CLI tests working without rebuilding workspace dependencies. diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index 6667916d8da..b823cf7da59 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -1,4 +1,5 @@ -import type { Api, Model, Provider } from "@earendil-works/pi-ai"; +import { stat } from "node:fs/promises"; +import type { Api, Model, ModelsStoreEntry, Provider } from "@earendil-works/pi-ai"; import { VERSION } from "../config.ts"; import { getPiUserAgent } from "../utils/pi-user-agent.ts"; @@ -29,8 +30,26 @@ function parseCatalog(providerId: string, value: unknown): Model[] { .map((model) => ({ ...model, provider: providerId })); } +function remoteModels( + entry: ModelsStoreEntry | undefined, + localLastModified: number | undefined, +): readonly Model[] { + if (!entry) return []; + if ( + localLastModified !== undefined && + (entry.lastModified === undefined || entry.lastModified <= localLastModified) + ) { + return []; + } + return entry.models; +} + /** Add a persisted pi.dev catalog overlay to a static built-in provider. */ -export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL): Provider { +export function withRemoteCatalog( + provider: Provider, + catalogBaseUrl: string = DEFAULT_CATALOG_BASE_URL, + localCatalogUrl?: URL, +): Provider { let dynamicModels: readonly Model[] = []; let inflightRefresh: Promise | undefined; @@ -40,12 +59,21 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D refreshModels: (context) => { inflightRefresh ??= (async () => { try { + const localLastModified = localCatalogUrl + ? await stat(localCatalogUrl).then( + (value) => value.mtimeMs, + () => undefined, + ) + : undefined; const stored = await context.store.read(); - if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id); + dynamicModels = remoteModels(stored, localLastModified).filter( + (model) => model.provider === provider.id, + ); if (!context.allowNetwork || context.signal?.aborted) return; if ( !context.force && stored?.checkedAt !== undefined && + stored.lastModified !== undefined && Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS ) { return; @@ -62,17 +90,23 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D if (context.signal?.aborted) return; const checkedAt = Date.now(); if (response.status === 404 || response.status === 501) { - await context.store.write({ models: dynamicModels, checkedAt }); + await context.store.write({ ...(stored ?? { models: [] }), checkedAt, lastModified: 0 }); return; } if (!response.ok) { - await context.store.write({ models: dynamicModels, checkedAt }); + await context.store.write({ ...(stored ?? { models: [] }), checkedAt }); throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`); } const refreshed = parseCatalog(provider.id, await response.json()); + const lastModified = Date.parse(response.headers.get("last-modified") ?? ""); if (context.signal?.aborted) return; - dynamicModels = refreshed; - await context.store.write({ models: refreshed, checkedAt }); + const entry = { + models: refreshed, + checkedAt, + lastModified: Number.isNaN(lastModified) ? 0 : lastModified, + }; + dynamicModels = remoteModels(entry, localLastModified); + await context.store.write(entry); } finally { inflightRefresh = undefined; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index a640f638032..352f3d32e91 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -294,7 +294,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} tools: [], }, convertToLlm: convertToLlmWithBlockImages, - streamFn: async (model, context, options) => { + streamFunction: async (model, context, options) => { const providerRetrySettings = settingsManager.getProviderRetrySettings(); const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs(); // SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout". diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index fab590ed5d4..42a4914e682 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1,5 +1,5 @@ -import { type AgentMessage, uuidv7 } from "@earendil-works/pi-agent-core"; -import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { type ImageContent, type Message, type TextContent, type Usage, uuidv7 } from "@earendil-works/pi-ai"; import { randomUUID } from "crypto"; import { appendFileSync, @@ -79,6 +79,8 @@ export interface CompactionEntry extends SessionEntryBase { tokensBefore: number; /** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */ details?: T; + /** Usage from the LLM call(s) that generated this summary, if available */ + usage?: Usage; /** True if generated by an extension, undefined/false if pi-generated (backward compatible) */ fromHook?: boolean; } @@ -89,6 +91,8 @@ export interface BranchSummaryEntry extends SessionEntryBase { summary: string; /** Extension-specific data (not sent to LLM) */ details?: T; + /** Usage from the LLM call that generated this summary, if available */ + usage?: Usage; /** True if generated by an extension, false if pi-generated */ fromHook?: boolean; } @@ -491,6 +495,16 @@ export function getDefaultSessionDir(cwd: string, agentDir: string = getDefaultA } const SESSION_READ_BUFFER_SIZE = 1024 * 1024; +const SESSION_HEADER_READ_BUFFER_SIZE = 4096; +/** Bound synchronous header discovery while allowing large cwd and custom metadata fields. */ +const MAX_SESSION_HEADER_SCAN_BYTES = 1024 * 1024; + +class SessionHeaderScanLimitError extends Error { + constructor(filePath: string) { + super(`Session header exceeds ${MAX_SESSION_HEADER_SCAN_BYTES}-byte scan limit: ${filePath}`); + this.name = "SessionHeaderScanLimitError"; + } +} function parseSessionEntryLine(line: string): FileEntry | null { if (!line.trim()) return null; @@ -547,20 +561,69 @@ export function loadEntriesFromFile(filePath: string): FileEntry[] { return entries; } +/** + * Inspect a physical line while searching for the first parsed session entry. + * Blank and malformed lines are skipped to match loadEntriesFromFile(). + * Returns undefined to keep scanning, null for a parsed non-header entry, or the header. + */ +function parseSessionHeaderCandidate(line: string): SessionHeader | null | undefined { + if (!line.trim()) return undefined; + const entry = parseSessionEntryLine(line); + if (!entry) return undefined; + if (entry.type !== "session" || typeof (entry as { id?: unknown }).id !== "string") return null; + return entry; +} + function readSessionHeader(filePath: string): SessionHeader | null { + const fd = openSync(filePath, "r"); try { - const fd = openSync(filePath, "r"); - const buffer = Buffer.alloc(512); - const bytesRead = readSync(fd, buffer, 0, 512, 0); - closeSync(fd); - const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n")[0]; - if (!firstLine) return null; - const header = JSON.parse(firstLine) as Record; - if (header.type !== "session" || typeof header.id !== "string") { - return null; + const decoder = new StringDecoder("utf8"); + const buffer = Buffer.allocUnsafe(SESSION_HEADER_READ_BUFFER_SIZE); + const lineChunks: string[] = []; + let scannedBytes = 0; + + while (scannedBytes < MAX_SESSION_HEADER_SCAN_BYTES) { + const readLength = Math.min(buffer.length, MAX_SESSION_HEADER_SCAN_BYTES - scannedBytes); + const bytesRead = readSync(fd, buffer, 0, readLength, null); + if (bytesRead === 0) { + lineChunks.push(decoder.end()); + return parseSessionHeaderCandidate(lineChunks.join("")) ?? null; + } + scannedBytes += bytesRead; + + const chunk = decoder.write(buffer.subarray(0, bytesRead)); + let lineStart = 0; + let newlineIndex = chunk.indexOf("\n", lineStart); + while (newlineIndex !== -1) { + lineChunks.push(chunk.slice(lineStart, newlineIndex)); + const header = parseSessionHeaderCandidate(lineChunks.join("")); + if (header !== undefined) return header; + lineChunks.length = 0; + lineStart = newlineIndex + 1; + newlineIndex = chunk.indexOf("\n", lineStart); + } + lineChunks.push(chunk.slice(lineStart)); + } + + // Probe for EOF so a final header without a newline is allowed when it ends + // exactly at the scan limit. Any additional byte exceeds the bounded scan. + const probe = Buffer.allocUnsafe(1); + if (readSync(fd, probe, 0, probe.length, null) === 0) { + lineChunks.push(decoder.end()); + return parseSessionHeaderCandidate(lineChunks.join("")) ?? null; } - return header as unknown as SessionHeader; + throw new SessionHeaderScanLimitError(filePath); + } finally { + closeSync(fd); + } +} + +function readSessionHeaderForDiscovery(filePath: string): SessionHeader | null { + try { + return readSessionHeader(filePath); } catch { + // Discovery is best-effort: unreadable or oversized files are not sessions, + // and one corrupt file must not prevent other sessions from being found. return null; } } @@ -582,7 +645,7 @@ export function findMostRecentSession(sessionDir: string, cwd?: string): string const files = readdirSync(resolvedSessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => join(resolvedSessionDir, f)) - .map((path) => ({ path, header: readSessionHeader(path) })) + .map((path) => ({ path, header: readSessionHeaderForDiscovery(path) })) .filter( (file): file is { path: string; header: SessionHeader } => file.header !== null && @@ -593,6 +656,7 @@ export function findMostRecentSession(sessionDir: string, cwd?: string): string return files[0]?.path || null; } catch { + // Directory access and stat races make recent-session discovery unavailable. return null; } } @@ -813,6 +877,7 @@ export class SessionManager { sessionFile: string | undefined, persist: boolean, newSessionOptions?: NewSessionOptions, + preloadedFileEntries?: FileEntry[], ) { this.cwd = resolvePath(cwd); this.sessionDir = normalizePath(sessionDir); @@ -822,7 +887,7 @@ export class SessionManager { } if (sessionFile) { - this.setSessionFile(sessionFile); + this._setSessionFile(sessionFile, preloadedFileEntries); } else { this.newSession(newSessionOptions); } @@ -830,9 +895,13 @@ export class SessionManager { /** Switch to a different session file (used for resume and branching) */ setSessionFile(sessionFile: string): void { + this._setSessionFile(sessionFile); + } + + private _setSessionFile(sessionFile: string, preloadedFileEntries?: FileEntry[]): void { this.sessionFile = resolvePath(sessionFile); if (existsSync(this.sessionFile)) { - this.fileEntries = loadEntriesFromFile(this.sessionFile); + this.fileEntries = preloadedFileEntries ?? loadEntriesFromFile(this.sessionFile); // If file was empty, initialize it with a valid session header. If it was // non-empty but did not parse as a pi session, fail without modifying it. @@ -1063,6 +1132,7 @@ export class SessionManager { tokensBefore: number, details?: T, fromHook?: boolean, + usage?: Usage, ): string { const entry: CompactionEntry = { type: "compaction", @@ -1073,6 +1143,7 @@ export class SessionManager { firstKeptEntryId, tokensBefore, details, + usage, fromHook, }; this._appendEntry(entry); @@ -1339,7 +1410,13 @@ export class SessionManager { * Same as branch(), but also appends a branch_summary entry that captures * context from the abandoned conversation path. */ - branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromHook?: boolean): string { + branchWithSummary( + branchFromId: string | null, + summary: string, + details?: unknown, + fromHook?: boolean, + usage?: Usage, + ): string { if (branchFromId !== null && !this.byId.has(branchFromId)) { throw new Error(`Entry ${branchFromId} not found`); } @@ -1352,6 +1429,7 @@ export class SessionManager { fromId: branchFromId ?? "root", summary, details, + usage, fromHook, }; this._appendEntry(entry); @@ -1483,13 +1561,24 @@ export class SessionManager { */ static open(path: string, sessionDir?: string, cwdOverride?: string): SessionManager { const resolvedPath = resolvePath(path); - // Extract cwd from session header if possible, otherwise use process.cwd() - const entries = loadEntriesFromFile(resolvedPath); - const header = entries.find((e) => e.type === "session") as SessionHeader | undefined; - const cwd = cwdOverride ?? header?.cwd ?? process.cwd(); + let header: SessionHeader | null = null; + let preloadedFileEntries: FileEntry[] | undefined; + if (cwdOverride === undefined && existsSync(resolvedPath)) { + try { + header = readSessionHeader(resolvedPath); + } catch (error) { + if (!(error instanceof SessionHeaderScanLimitError)) throw error; + // The bounded scan is only a discovery optimization. A full load remains + // authoritative for legacy files with very large headers or prefixes. + preloadedFileEntries = loadEntriesFromFile(resolvedPath); + const firstEntry = preloadedFileEntries[0]; + header = firstEntry?.type === "session" ? firstEntry : null; + } + } + const cwd = cwdOverride ?? (header ? getSessionHeaderCwd(header) : undefined) ?? process.cwd(); // If no sessionDir provided, derive from file's parent directory const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, ".."); - return new SessionManager(cwd, dir, resolvedPath, true); + return new SessionManager(cwd, dir, resolvedPath, true, undefined, preloadedFileEntries); } /** diff --git a/packages/coding-agent/src/core/usage-totals.ts b/packages/coding-agent/src/core/usage-totals.ts new file mode 100644 index 00000000000..4ae55d0d2fa --- /dev/null +++ b/packages/coding-agent/src/core/usage-totals.ts @@ -0,0 +1,70 @@ +import type { Usage } from "@earendil-works/pi-ai/compat"; +import type { SessionEntry } from "./session-manager.ts"; + +export interface UsageTotals { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; +} + +export function createUsageTotals(): UsageTotals { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + }; +} + +export function addUsageToTotals(totals: UsageTotals, usage: Usage): void { + totals.input += usage.input; + totals.output += usage.output; + totals.cacheRead += usage.cacheRead; + totals.cacheWrite += usage.cacheWrite; + totals.cost += usage.cost.total; +} + +export interface UsageCostBreakdownEntry { + key: string; + cost: number; + tokens: number; +} + +/** Group attributable assistant usage by model and all other usage into a separate bucket. */ +export function getUsageCostBreakdown(entries: SessionEntry[]): UsageCostBreakdownEntry[] { + const totalsByKey = new Map(); + + for (const entry of entries) { + let key: string | undefined; + let usage: Usage | undefined; + if (entry.type === "message" && entry.message.role === "assistant") { + key = `${entry.message.provider}/${entry.message.responseModel ?? entry.message.model}`; + usage = entry.message.usage; + } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) { + key = "Tools/summaries"; + usage = entry.message.usage; + } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { + key = "Tools/summaries"; + usage = entry.usage; + } + if (!key || !usage) continue; + + let totals = totalsByKey.get(key); + if (!totals) { + totals = createUsageTotals(); + totalsByKey.set(key, totals); + } + addUsageToTotals(totals, usage); + } + + return Array.from(totalsByKey, ([key, totals]) => ({ + key, + cost: totals.cost, + tokens: totals.input + totals.output + totals.cacheRead + totals.cacheWrite, + })) + .filter((entry) => entry.cost > 0 || entry.tokens > 0) + .sort((a, b) => b.cost - a.cost); +} diff --git a/packages/coding-agent/src/extensions/llama/client.ts b/packages/coding-agent/src/extensions/llama/client.ts index 0e58e1bdd94..45cd8f3a650 100644 --- a/packages/coding-agent/src/extensions/llama/client.ts +++ b/packages/coding-agent/src/extensions/llama/client.ts @@ -107,9 +107,11 @@ function parseLoadProgress(data: unknown): LlamaProgress | undefined { function parseDownloadProgress(data: unknown): LlamaProgress | undefined { if (typeof data !== "object" || data === null) return undefined; + const nested = (data as { progress?: unknown }).progress; + const files = typeof nested === "object" && nested !== null ? nested : data; let done = 0; let total = 0; - for (const value of Object.values(data as Record)) { + for (const value of Object.values(files as Record)) { if (typeof value !== "object" || value === null) continue; const entry = value as { done?: unknown; total?: unknown }; if (typeof entry.done !== "number" || typeof entry.total !== "number") continue; diff --git a/packages/coding-agent/src/extensions/llama/index.ts b/packages/coding-agent/src/extensions/llama/index.ts index 4a38b3c62c7..5fc17b997e3 100644 --- a/packages/coding-agent/src/extensions/llama/index.ts +++ b/packages/coding-agent/src/extensions/llama/index.ts @@ -88,7 +88,7 @@ export default function llamaExtension(pi: ExtensionAPI): void { model: target.id, initialMessage: "Starting…", cancelTitle: "Stop loading?", - cancelMessage: `Stop loading ${target.id}?`, + cancelMessage: target.id, run: (signal, update) => client.loadAndWait(target.id, update, signal), cancel: () => client.unload(target.id), }); @@ -119,7 +119,7 @@ export default function llamaExtension(pi: ExtensionAPI): void { client: LlamaClient, model: LlamaModelInfo, ): Promise => { - if (!(await ui.confirm("Unload model?", `Unload ${model.id}?`))) return; + if (!(await ui.confirm("Unload model?", model.id))) return; await client.unloadAndWait(model.id); await syncCatalog(ctx, client); ctx.ui.notify(`Unloaded ${model.id}`); @@ -162,7 +162,7 @@ export default function llamaExtension(pi: ExtensionAPI): void { model, initialMessage: "Starting…", cancelTitle: "Stop download?", - cancelMessage: `Stop downloading ${model}?`, + cancelMessage: model, run: (signal, update) => client.downloadAndWait(model, update, signal), cancel: () => client.unload(model), }); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 26757fb831f..e223d3144aa 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -42,6 +42,7 @@ export { type GenerateBranchSummaryOptions, generateBranchSummary, generateSummary, + generateSummaryWithUsage, getLastAssistantUsage, prepareBranchEntries, serializeConversation, @@ -99,8 +100,11 @@ export type { KeybindingsManager, LoadExtensionsResult, LsToolCallEvent, + MessageEndEvent, MessageRenderer, MessageRenderOptions, + MessageStartEvent, + MessageUpdateEvent, ProjectTrustContext, ProjectTrustEvent, ProjectTrustEventDecision, @@ -128,7 +132,10 @@ export type { ToolCallEvent, ToolCallEventResult, ToolDefinition, + ToolExecutionEndEvent, ToolExecutionMode, + ToolExecutionStartEvent, + ToolExecutionUpdateEvent, ToolInfo, ToolRenderResultOptions, ToolResultEvent, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7d9c5525349..e004436f2d4 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -709,7 +709,7 @@ export async function main(args: string[], options?: MainOptions) { message: "--api-key requires a model to be specified via --model, --provider/--model, or --models", }); } else { - await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey); + await modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey, { allowNetwork: false }); await services.modelRuntime.getAvailable(); } } @@ -808,6 +808,10 @@ export async function main(args: string[], options?: MainOptions) { process.exit(1); } + if (!offlineMode && (appMode === "interactive" || appMode === "rpc")) { + void modelRuntime.refresh().catch(() => {}); + } + if (appMode === "rpc") { printTimings(); await runRpcMode(runtime); diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index fa94748069f..44cf886b3dd 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -3,6 +3,7 @@ import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/p import type { AgentSession } from "../../../core/agent-session.ts"; import { areExperimentalFeaturesEnabled } from "../../../core/experimental.ts"; import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.ts"; +import { addUsageToTotals, createUsageTotals } from "../../../core/usage-totals.ts"; import { theme } from "../theme/theme.ts"; /** @@ -84,25 +85,21 @@ export class FooterComponent implements Component { const state = this.session.state; // Calculate cumulative usage from ALL session entries (not just post-compaction messages) - let totalInput = 0; - let totalOutput = 0; - let totalCacheRead = 0; - let totalCacheWrite = 0; - let totalCost = 0; + const usageTotals = createUsageTotals(); let latestCacheHitRate: number | undefined; for (const entry of this.session.sessionManager.getEntries()) { if (entry.type === "message" && entry.message.role === "assistant") { - totalInput += entry.message.usage.input; - totalOutput += entry.message.usage.output; - totalCacheRead += entry.message.usage.cacheRead; - totalCacheWrite += entry.message.usage.cacheWrite; - totalCost += entry.message.usage.cost.total; + addUsageToTotals(usageTotals, entry.message.usage); const latestPromptTokens = entry.message.usage.input + entry.message.usage.cacheRead + entry.message.usage.cacheWrite; latestCacheHitRate = latestPromptTokens > 0 ? (entry.message.usage.cacheRead / latestPromptTokens) * 100 : undefined; + } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) { + addUsageToTotals(usageTotals, entry.message.usage); + } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { + addUsageToTotals(usageTotals, entry.usage); } } @@ -130,19 +127,20 @@ export class FooterComponent implements Component { // Build stats line const statsParts = []; - if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`); - if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); - if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); - if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); - if ((totalCacheRead > 0 || totalCacheWrite > 0) && latestCacheHitRate !== undefined) { + if (usageTotals.input) statsParts.push(`↑${formatTokens(usageTotals.input)}`); + if (usageTotals.output) statsParts.push(`↓${formatTokens(usageTotals.output)}`); + if (usageTotals.cacheRead) statsParts.push(`R${formatTokens(usageTotals.cacheRead)}`); + if (usageTotals.cacheWrite) statsParts.push(`W${formatTokens(usageTotals.cacheWrite)}`); + if ((usageTotals.cacheRead > 0 || usageTotals.cacheWrite > 0) && latestCacheHitRate !== undefined) { statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`); } + // Kimi Coding is subscription-backed despite using API-key authentication. const usingSubscription = state.model ? state.model.provider === "kimi-coding" || this.session.modelRuntime.isUsingOAuth(state.model.provider) : false; - if (totalCost || usingSubscription) { - const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; + if (usageTotals.cost || usingSubscription) { + const costStr = `$${usageTotals.cost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; statsParts.push(costStr); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index cc9d13b3ef0..c391a98d9f3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -86,6 +86,7 @@ import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; import type { TruncationResult } from "../../core/tools/truncate.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts"; +import { getUsageCostBreakdown } from "../../core/usage-totals.ts"; import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts"; import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; @@ -5600,22 +5601,9 @@ export class InteractiveMode { const cacheWaste = computeCacheWaste(entries, this.session.modelRuntime); // Cost/token totals per provider/model actually used (e.g. OpenRouter `auto` - // resolves to a concrete responseModel), sorted by cost descending. - const perModelMap = new Map(); - for (const entry of entries) { - if (entry.type !== "message" || entry.message.role !== "assistant") continue; - const message = entry.message; - const usage = message.usage; - const key = `${message.provider}/${message.responseModel ?? message.model}`; - let bucket = perModelMap.get(key); - if (!bucket) { - bucket = { key, cost: 0, tokens: 0 }; - perModelMap.set(key, bucket); - } - bucket.cost += usage.cost.total; - bucket.tokens += usage.input + usage.output + usage.cacheRead + usage.cacheWrite; - } - const perModel = Array.from(perModelMap.values()).sort((a, b) => b.cost - a.cost); + // resolves to a concrete responseModel). Usage without model attribution is + // grouped separately so the breakdown reconciles with the session total. + const usageBreakdown = getUsageCostBreakdown(entries); let info = `${theme.bold("Session Info")}\n\n`; if (sessionName) { @@ -5649,8 +5637,8 @@ export class InteractiveMode { if (stats.cost > 0 || cacheWaste.missedTokens > 0) { info += `\n${theme.bold("Cost")}\n`; info += `${theme.fg("dim", "Total:")} $${stats.cost.toFixed(3)}`; - if (perModel.length > 1) { - for (const entry of perModel) { + if (usageBreakdown.length > 1) { + for (const entry of usageBreakdown) { info += `\n ${theme.fg("dim", `${entry.key}:`)} $${entry.cost.toFixed(3)} ${theme.fg("dim", `(${formatTokens(entry.tokens)} tokens)`)}`; } } diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 934c6f1fc2f..79cd7f2f9ef 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -280,6 +280,14 @@ export class RpcClient { return this.getData(response); } + /** + * Get list of available thinking levels for the current model. + */ + async getAvailableThinkingLevels(): Promise { + const response = await this.send({ type: "get_available_thinking_levels" }); + return this.getData<{ levels: ThinkingLevel[] }>(response).levels; + } + /** * Set steering mode. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 8b7b8b7fb60..349f6774b61 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -506,6 +506,11 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { beforeEach(async () => { tempDir = join(tmpdir(), `pi-auto-compaction-queue-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); - vi.useFakeTimers(); const model = getModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ + streamFunction: streamSimple, initialState: { model, systemPrompt: "Test", @@ -50,7 +50,6 @@ describe("AgentSession auto-compaction queue resume", () => { afterEach(() => { session.dispose(); - vi.useRealTimers(); vi.restoreAllMocks(); if (tempDir && existsSync(tempDir)) { rmSync(tempDir, { recursive: true }); @@ -84,9 +83,9 @@ describe("AgentSession auto-compaction queue resume", () => { timestamp: now - 500, }); session.agent.state.messages = sessionManager.buildSessionContext().messages; - session.agent.streamFn = (summaryModel) => { + session.agent.streamFunction = (summaryModel) => { const stream = createAssistantMessageEventStream(); - queueMicrotask(() => { + void Promise.resolve().then(() => { stream.push({ type: "done", reason: "stop", diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index 61ad0a2b088..e35158f80cb 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -12,7 +12,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; -import { getModel } from "@earendil-works/pi-ai/compat"; +import { getModel, streamSimple } from "@earendil-works/pi-ai/compat"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -49,6 +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, initialState: { model, systemPrompt: "You are a helpful assistant. Be concise.", diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index bd825ec7e6d..bd3ef463d7d 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -90,7 +90,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [], }, - streamFn: (_model, _context, options) => { + streamFunction: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -195,7 +195,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [], }, - streamFn: (_model, context, options) => { + streamFunction: (_model, context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { @@ -301,7 +301,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [], }, - streamFn: () => { + streamFunction: () => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); @@ -362,7 +362,7 @@ describe("AgentSession concurrent prompt guard", () => { systemPrompt: "Test", tools: [tool], }, - streamFn: async (_model, context) => { + streamFunction: 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], }, - streamFn: async (_model, context) => { + streamFunction: async (_model, context) => { const stream = new MockAssistantStream(); queueMicrotask(() => { const hasToolResult = context.messages.some((message) => message.role === "toolResult"); diff --git a/packages/coding-agent/test/agent-session-dynamic-provider.test.ts b/packages/coding-agent/test/agent-session-dynamic-provider.test.ts index ce137d2e8e2..c806efbc37d 100644 --- a/packages/coding-agent/test/agent-session-dynamic-provider.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-provider.test.ts @@ -84,7 +84,7 @@ describe("AgentSession dynamic provider registration", () => { session: Awaited>, ): Promise { let baseUrl: string | undefined; - session.agent.streamFn = async (model) => { + session.agent.streamFunction = async (model) => { baseUrl = model.baseUrl; throw new Error("stop"); }; diff --git a/packages/coding-agent/test/agent-session-retry.test.ts b/packages/coding-agent/test/agent-session-retry.test.ts index f7d5eced7b7..f61e71cb4ba 100644 --- a/packages/coding-agent/test/agent-session-retry.test.ts +++ b/packages/coding-agent/test/agent-session-retry.test.ts @@ -82,7 +82,7 @@ describe("AgentSession retry", () => { const agent = new Agent({ getApiKey: () => "test-key", initialState: { model, systemPrompt: "Test", tools: [] }, - streamFn: () => { + streamFunction: () => { 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: [] }, - streamFn, + streamFunction: 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: [] }, - streamFn: () => { + streamFunction: () => { callCount++; const stream = new MockAssistantStream(); queueMicrotask(() => { diff --git a/packages/coding-agent/test/agent-session-stats.test.ts b/packages/coding-agent/test/agent-session-stats.test.ts index 9a442c73ccd..37706c56654 100644 --- a/packages/coding-agent/test/agent-session-stats.test.ts +++ b/packages/coding-agent/test/agent-session-stats.test.ts @@ -1,10 +1,17 @@ import { Agent } from "@earendil-works/pi-agent-core"; -import { type AssistantMessage, getModel, type Usage } from "@earendil-works/pi-ai/compat"; +import { + type AssistantMessage, + getModel, + streamSimple, + type ToolResultMessage, + type Usage, +} from "@earendil-works/pi-ai/compat"; import { describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; import { SessionManager } from "../src/core/session-manager.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; +import { getUsageCostBreakdown } from "../src/core/usage-totals.ts"; import { createInMemoryModelRegistry, getModelRuntime } from "./model-runtime-test-utils.ts"; import { createTestResourceLoader } from "./utilities.ts"; @@ -48,6 +55,18 @@ function createUserMessage(text: string, timestamp: number) { }; } +function createToolResultMessage(usage: Usage): ToolResultMessage { + return { + role: "toolResult", + toolCallId: "tool-call-1", + toolName: "test_tool", + content: [{ type: "text", text: "tool result" }], + usage, + isError: false, + timestamp: 1, + }; +} + async function createSession() { const settingsManager = SettingsManager.inMemory(); const sessionManager = SessionManager.inMemory(); @@ -56,6 +75,7 @@ async function createSession() { const session = new AgentSession({ agent: new Agent({ getApiKey: () => "test-key", + streamFunction: streamSimple, initialState: { model, systemPrompt: "You are a helpful assistant.", @@ -143,6 +163,100 @@ describe("AgentSession.getSessionStats", () => { } }); + it("includes branch summary usage in session totals", async () => { + const { session, sessionManager } = await createSession(); + + try { + sessionManager.branchWithSummary(null, "summary", undefined, false, { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }); + syncAgentMessages(session, sessionManager); + + const stats = session.getSessionStats(); + expect(stats.tokens).toEqual({ input: 10, output: 20, cacheRead: 30, cacheWrite: 40, total: 100 }); + expect(stats.cost).toBe(1); + } finally { + session.dispose(); + } + }); + + it("includes compaction usage in session totals", async () => { + const { session, sessionManager } = await createSession(); + + try { + const firstKeptEntryId = sessionManager.appendMessage(createUserMessage("hello", 1)); + sessionManager.appendCompaction("summary", firstKeptEntryId, 100, undefined, false, { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }); + syncAgentMessages(session, sessionManager); + + const stats = session.getSessionStats(); + expect(stats.tokens).toEqual({ input: 10, output: 20, cacheRead: 30, cacheWrite: 40, total: 100 }); + expect(stats.cost).toBe(1); + } finally { + session.dispose(); + } + }); + + it("includes tool result usage in session totals", async () => { + const { session, sessionManager } = await createSession(); + + try { + sessionManager.appendMessage( + createToolResultMessage({ + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }), + ); + syncAgentMessages(session, sessionManager); + + const stats = session.getSessionStats(); + expect(stats.tokens).toEqual({ input: 10, output: 20, cacheRead: 30, cacheWrite: 40, total: 100 }); + expect(stats.cost).toBe(1); + } finally { + session.dispose(); + } + }); + + it("groups tool and summary usage separately from model-attributed usage", () => { + const sessionManager = SessionManager.inMemory(); + const rootId = sessionManager.appendMessage(createUserMessage("hello", 1)); + sessionManager.appendMessage({ + ...createAssistantMessage("response", 100, 2), + usage: { ...createUsage(100), cost: { ...createUsage(100).cost, total: 0.5 } }, + }); + sessionManager.appendMessage( + createToolResultMessage({ ...createUsage(100), cost: { ...createUsage(100).cost, total: 1 } }), + ); + sessionManager.appendCompaction("summary", rootId, 100, undefined, false, { + ...createUsage(100), + cost: { ...createUsage(100).cost, total: 2 }, + }); + sessionManager.branchWithSummary(null, "branch summary", undefined, false, { + ...createUsage(100), + cost: { ...createUsage(100).cost, total: 3 }, + }); + + expect(getUsageCostBreakdown(sessionManager.getEntries())).toEqual([ + { key: "Tools/summaries", cost: 6, tokens: 300 }, + { key: `${model.provider}/${model.id}`, cost: 0.5, tokens: 100 }, + ]); + }); + it("ignores zero-usage messages when checking for post-compaction context usage", async () => { const { session, sessionManager } = await createSession(); diff --git a/packages/coding-agent/test/branch-summary-extensions.test.ts b/packages/coding-agent/test/branch-summary-extensions.test.ts new file mode 100644 index 00000000000..9c47cb81aac --- /dev/null +++ b/packages/coding-agent/test/branch-summary-extensions.test.ts @@ -0,0 +1,55 @@ +import type { Usage } from "@earendil-works/pi-ai/compat"; +import { afterEach, describe, expect, it } from "vitest"; +import { createHarness, type Harness } from "./suite/harness.ts"; +import { assistantMsg, userMsg } from "./utilities.ts"; + +describe("Branch summary extensions", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) { + harnesses.pop()?.cleanup(); + } + }); + + it("persists extension-provided summary usage in session totals", async () => { + const usage: Usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("session_before_tree", () => ({ + summary: { + summary: "Summary provided by extension", + usage, + }, + })); + }, + ], + }); + harnesses.push(harness); + + const targetId = harness.sessionManager.appendMessage(userMsg("first branch")); + harness.sessionManager.appendMessage(assistantMsg("first reply")); + harness.sessionManager.appendMessage(userMsg("abandoned branch work")); + harness.sessionManager.appendMessage(assistantMsg("abandoned reply")); + + const result = await harness.session.navigateTree(targetId, { summarize: true }); + const summaryEntry = result.summaryEntry; + + expect(summaryEntry?.type).toBe("branch_summary"); + expect(summaryEntry?.fromHook).toBe(true); + expect(summaryEntry?.summary).toBe("Summary provided by extension"); + expect(summaryEntry?.usage).toEqual(usage); + + const stats = harness.session.getSessionStats(); + expect(stats.tokens).toEqual({ input: 12, output: 22, cacheRead: 30, cacheWrite: 40, total: 104 }); + expect(stats.cost).toBe(1); + }); +}); diff --git a/packages/coding-agent/test/compaction-extensions.test.ts b/packages/coding-agent/test/compaction-extensions.test.ts index fabcb38dc35..053be534c27 100644 --- a/packages/coding-agent/test/compaction-extensions.test.ts +++ b/packages/coding-agent/test/compaction-extensions.test.ts @@ -7,7 +7,7 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; -import { getModel } from "@earendil-works/pi-ai/compat"; +import { getModel, streamSimple } from "@earendil-works/pi-ai/compat"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -89,6 +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, initialState: { model, systemPrompt: "You are a helpful assistant. Be concise.", diff --git a/packages/coding-agent/test/compaction-summary-reasoning.test.ts b/packages/coding-agent/test/compaction-summary-reasoning.test.ts index 306a3b1c4ed..417662385fa 100644 --- a/packages/coding-agent/test/compaction-summary-reasoning.test.ts +++ b/packages/coding-agent/test/compaction-summary-reasoning.test.ts @@ -1,7 +1,12 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { type CompactionPreparation, compact, generateSummary } from "../src/core/compaction/index.ts"; +import { + type CompactionPreparation, + compact, + generateSummary, + generateSummaryWithUsage, +} from "../src/core/compaction/index.ts"; const { completeSimpleMock } = vi.hoisted(() => ({ completeSimpleMock: vi.fn(), @@ -57,7 +62,7 @@ describe("generateSummary reasoning options", () => { }); it("uses the provided thinking level for reasoning-capable models", async () => { - await generateSummary( + const result = await generateSummaryWithUsage( messages, createModel(true), 2000, @@ -69,6 +74,9 @@ describe("generateSummary reasoning options", () => { "medium", ); + expect(result.text).toBe("## Goal\nTest summary"); + expect(result.usage).toEqual(mockSummaryResponse.usage); + expect(completeSimpleMock).toHaveBeenCalledTimes(1); expect(completeSimpleMock.mock.calls[0][2]).toMatchObject({ reasoning: "medium", @@ -76,6 +84,12 @@ describe("generateSummary reasoning options", () => { }); }); + it("preserves the string result from generateSummary", async () => { + await expect(generateSummary(messages, createModel(false), 2000, "test-key")).resolves.toBe( + "## Goal\nTest summary", + ); + }); + it("does not set reasoning when thinking is off", async () => { await generateSummary( messages, @@ -127,8 +141,15 @@ describe("generateSummary reasoning options", () => { settings: { enabled: true, reserveTokens: 500000, keepRecentTokens: 20000 }, }; - await compact(preparation, createModel(false, 128000), "test-key"); + const result = await compact(preparation, createModel(false, 128000), "test-key"); + expect(result.usage).toEqual({ + ...mockSummaryResponse.usage, + input: 20, + output: 20, + totalTokens: 40, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }); expect(completeSimpleMock.mock.calls.map((call) => call[2]?.maxTokens)).toEqual([128000, 128000]); }); }); diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts index 85956d5393f..310114a47a2 100644 --- a/packages/coding-agent/test/footer-width.test.ts +++ b/packages/coding-agent/test/footer-width.test.ts @@ -21,20 +21,46 @@ function createSession(options: { reasoning?: boolean; thinkingLevel?: string; usage?: AssistantUsage; + branchUsage?: AssistantUsage; + compactionUsage?: AssistantUsage; + toolUsage?: AssistantUsage; }): AgentSession { const usage = options.usage; - const entries = - usage === undefined - ? [] - : [ - { - type: "message", - message: { - role: "assistant", - usage, - }, - }, - ]; + const entries: Array> = []; + + if (usage !== undefined) { + entries.push({ + type: "message", + message: { + role: "assistant", + usage, + }, + }); + } + + if (options.branchUsage !== undefined) { + entries.push({ + type: "branch_summary", + usage: options.branchUsage, + }); + } + + if (options.compactionUsage !== undefined) { + entries.push({ + type: "compaction", + usage: options.compactionUsage, + }); + } + + if (options.toolUsage !== undefined) { + entries.push({ + type: "message", + message: { + role: "toolResult", + usage: options.toolUsage, + }, + }); + } const session = { state: { @@ -125,6 +151,44 @@ describe("FooterComponent width handling", () => { } }); + it("includes summary and tool result usage in the total cost", () => { + const session = createSession({ + sessionName: "", + usage: { + input: 100, + output: 10, + cacheRead: 0, + cacheWrite: 0, + cost: { total: 0.5 }, + }, + branchUsage: { + input: 20, + output: 5, + cacheRead: 0, + cacheWrite: 0, + cost: { total: 0.25 }, + }, + compactionUsage: { + input: 5, + output: 2, + cacheRead: 0, + cacheWrite: 0, + cost: { total: 0.125 }, + }, + toolUsage: { + input: 15, + output: 3, + cacheRead: 0, + cacheWrite: 0, + cost: { total: 0.375 }, + }, + }); + const footer = new FooterComponent(session, createFooterData(1)); + + const statsLine = stripAnsi(footer.render(120)[1]); + expect(statsLine).toContain("$1.250"); + }); + it("shows the latest cache hit rate when cache usage is present", () => { const session = createSession({ sessionName: "", diff --git a/packages/coding-agent/test/llama-extension.test.ts b/packages/coding-agent/test/llama-extension.test.ts index 3841af53549..19dbdee7d56 100644 --- a/packages/coding-agent/test/llama-extension.test.ts +++ b/packages/coding-agent/test/llama-extension.test.ts @@ -220,7 +220,7 @@ describe("llama.cpp extension", () => { send({ model: "owner/repo:Q4_K_M", event: "download_progress", - data: { "https://example/model.gguf": { done: 512, total: 1024 } }, + data: { progress: { "https://example/model.gguf": { done: 512, total: 1024 } } }, }); status = "unloaded"; send({ model: "owner/repo:Q4_K_M", event: "download_finished", data: {} }); diff --git a/packages/coding-agent/test/queued-extension-commands.test.ts b/packages/coding-agent/test/queued-extension-commands.test.ts index f1210e65244..1798f957a54 100644 --- a/packages/coding-agent/test/queued-extension-commands.test.ts +++ b/packages/coding-agent/test/queued-extension-commands.test.ts @@ -72,7 +72,7 @@ async function createSession( let streamCall = 0; const model = getModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ - streamFn: () => { + streamFunction: () => { streamCall += 1; const stream = new MockAssistantStream(); queueMicrotask(() => { diff --git a/packages/coding-agent/test/radius.test.ts b/packages/coding-agent/test/radius.test.ts index de3836727bc..8a7f5965a8b 100644 --- a/packages/coding-agent/test/radius.test.ts +++ b/packages/coding-agent/test/radius.test.ts @@ -91,6 +91,20 @@ describe("Radius provider", () => { expect(vi.mocked(fetch).mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer access-token" }); }); + it("does not refresh catalogs over the network by default", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const runtime = await ModelRuntime.create({ + credentials: AuthStorage.inMemory({ + [RADIUS_PROVIDER_ID]: radiusOAuthCredential("https://radius.example.com/v1"), + }), + modelsStore: new InMemoryModelsStore(), + modelsPath: null, + }); + + expect(runtime.getModel(RADIUS_PROVIDER_ID, "auto")).toBeDefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("does not fetch or expose Radius models without configured auth", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); const runtime = await ModelRuntime.create({ diff --git a/packages/coding-agent/test/remote-catalog-provider.test.ts b/packages/coding-agent/test/remote-catalog-provider.test.ts index c8c5f869839..8b43a4f3262 100644 --- a/packages/coding-agent/test/remote-catalog-provider.test.ts +++ b/packages/coding-agent/test/remote-catalog-provider.test.ts @@ -1,4 +1,11 @@ -import { createProvider, InMemoryModelsStore, type Model } from "@earendil-works/pi-ai"; +import { statSync } from "node:fs"; +import { + createProvider, + InMemoryModelsStore, + type Model, + type ModelsStoreEntry, + type ProviderModelsStore, +} from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { VERSION } from "../src/config.ts"; import { withRemoteCatalog } from "../src/core/remote-catalog-provider.ts"; @@ -18,6 +25,34 @@ function model(id: string): Model<"openai-completions"> { }; } +function testProvider(localCatalogUrl?: URL) { + return withRemoteCatalog( + createProvider({ + id: "test-provider", + auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } }, + models: [model("static")], + api: { + stream: () => { + throw new Error("not used"); + }, + streamSimple: () => { + throw new Error("not used"); + }, + }, + }), + "https://pi.dev", + localCatalogUrl, + ); +} + +function scopedStore(store: InMemoryModelsStore): ProviderModelsStore { + return { + read: () => store.read("test-provider"), + write: (entry: ModelsStoreEntry) => store.write("test-provider", entry), + delete: () => store.delete("test-provider"), + }; +} + afterEach(() => vi.restoreAllMocks()); describe("remote catalog provider", () => { @@ -29,50 +64,12 @@ describe("remote catalog provider", () => { headers: { "content-type": "application/json" }, }), ); - const provider = withRemoteCatalog( - createProvider({ - id: "test-provider", - auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } }, - models: [model("static")], - api: { - stream: () => { - throw new Error("not used"); - }, - streamSimple: () => { - throw new Error("not used"); - }, - }, - }), - ); + const provider = testProvider(); const store = new InMemoryModelsStore(); - await provider.refreshModels?.({ - credential: { type: "api_key" }, - store: { - read: () => store.read(provider.id), - write: (entry) => store.write(provider.id, entry), - delete: () => store.delete(provider.id), - }, - allowNetwork: true, - }); - await provider.refreshModels?.({ - credential: { type: "api_key" }, - store: { - read: () => store.read(provider.id), - write: (entry) => store.write(provider.id, entry), - delete: () => store.delete(provider.id), - }, - allowNetwork: true, - }); - await provider.refreshModels?.({ - credential: { type: "api_key" }, - store: { - read: () => store.read(provider.id), - write: (entry) => store.write(provider.id, entry), - delete: () => store.delete(provider.id), - }, - allowNetwork: true, - force: true, - }); + const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true }; + await provider.refreshModels?.(refresh); + await provider.refreshModels?.(refresh); + await provider.refreshModels?.({ ...refresh, force: true }); expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]); expect((await store.read(provider.id))?.models.map((entry) => entry.id)).toEqual(["dynamic"]); @@ -82,33 +79,40 @@ describe("remote catalog provider", () => { }); }); + it("prefers the newer of the generated and remote catalogs", async () => { + const localCatalogUrl = new URL(import.meta.url); + const localMtime = statSync(localCatalogUrl).mtimeMs; + const newerHeader = new Date(localMtime + 60_000).toUTCString(); + const responses = [ + new Response(JSON.stringify({ old: model("old") }), { + headers: { "last-modified": new Date(localMtime - 60_000).toUTCString() }, + }), + new Response(JSON.stringify({ newer: model("newer") }), { + headers: { "last-modified": newerHeader }, + }), + ]; + vi.spyOn(globalThis, "fetch").mockImplementation(async () => responses.shift() as Response); + const provider = testProvider(localCatalogUrl); + const store = new InMemoryModelsStore(); + const refresh = { credential: { type: "api_key" } as const, store: scopedStore(store), allowNetwork: true }; + + await provider.refreshModels?.(refresh); + expect(provider.getModels().map((entry) => entry.id)).toEqual(["static"]); + + await provider.refreshModels?.({ ...refresh, force: true }); + expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "newer"]); + expect(await store.read(provider.id)).toMatchObject({ lastModified: Date.parse(newerHeader) }); + }); + it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not implemented", { status: 501 })); - const provider = withRemoteCatalog( - createProvider({ - id: "test-provider", - auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } }, - models: [model("static")], - api: { - stream: () => { - throw new Error("not used"); - }, - streamSimple: () => { - throw new Error("not used"); - }, - }, - }), - ); + const provider = testProvider(); const store = new InMemoryModelsStore(); await expect( provider.refreshModels?.({ credential: { type: "api_key" }, - store: { - read: () => store.read(provider.id), - write: (entry) => store.write(provider.id, entry), - delete: () => store.delete(provider.id), - }, + store: scopedStore(store), allowNetwork: true, }), ).resolves.toBeUndefined(); diff --git a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts index 155d2d384a5..8279d57c737 100644 --- a/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts +++ b/packages/coding-agent/test/rpc-prompt-response-semantics.test.ts @@ -114,7 +114,7 @@ async function createRuntimeHost(options: { withAuth: boolean; responseDelayMs: systemPrompt: "Test", tools: [], }, - streamFn: (_model, _context, _options) => { + streamFunction: (_model, _context, _options) => { const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index faedcb89f1e..315fd27a4c9 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -209,6 +209,28 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T expect(newState.thinkingLevel).toBe(result!.level); }, 30000); + test("should get available thinking levels", async () => { + await client.start(); + + const levels = await client.getAvailableThinkingLevels(); + expect(levels.length).toBeGreaterThan(0); + + // The current level reported by get_state must be in the available list + const state = await client.getState(); + expect(levels).toContain(state.thinkingLevel); + + // cycle_thinking_level must only ever land on levels from get_available_thinking_levels + const initialLevel = state.thinkingLevel; + const cycled = await client.cycleThinkingLevel(); + if (cycled) { + expect(levels).toContain(cycled.level); + // distinct cycle step (unless only one level) + if (levels.length > 1) { + expect(cycled.level).not.toBe(initialLevel); + } + } + }, 30000); + test("should get available models", async () => { await client.start(); diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts index 53f7e9427de..8cafaccdc0f 100644 --- a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -126,7 +126,7 @@ describe("createAgentSession provider attribution headers", () => { }); try { - const stream = await session.agent.streamFn( + const stream = await session.agent.streamFunction( model, { messages: [] }, { diff --git a/packages/coding-agent/test/sdk-stream-options.test.ts b/packages/coding-agent/test/sdk-stream-options.test.ts index f61cade088a..f4c2fd9482d 100644 --- a/packages/coding-agent/test/sdk-stream-options.test.ts +++ b/packages/coding-agent/test/sdk-stream-options.test.ts @@ -114,7 +114,7 @@ describe("createAgentSession stream options", () => { }); try { - const stream = await session.agent.streamFn(model, { messages: [] }, requestOptions); + const stream = await session.agent.streamFunction(model, { messages: [] }, requestOptions); await stream.result(); return capturedOptions; } finally { diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index d4ec0d2f9b2..c978b320025 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -5,6 +5,8 @@ import { join } from "path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.ts"; +const HEADER_SCAN_LIMIT_BYTES = 1024 * 1024; + describe("loadEntriesFromFile", () => { let tempDir: string; @@ -17,6 +19,19 @@ describe("loadEntriesFromFile", () => { rmSync(tempDir, { recursive: true, force: true }); }); + function writeSessionHeader(file: string, cwd: string, id: string, prefix = ""): void { + writeFileSync( + file, + `${prefix}${JSON.stringify({ + type: "session", + version: 3, + id, + timestamp: "2025-01-01T00:00:00Z", + cwd, + })}\n`, + ); + } + it("returns empty array for non-existent file", () => { const entries = loadEntriesFromFile(join(tempDir, "nonexistent.jsonl")); expect(entries).toEqual([]); @@ -65,6 +80,43 @@ describe("loadEntriesFromFile", () => { expect(entries).toHaveLength(2); }); + it.each([ + ["leading blank lines", "\n \n", "leading-blank"], + ["leading malformed lines", "not json\n{broken json\n", "leading-malformed"], + ["a multi-buffer header", "", "a".repeat(8192)], + ])("reads cwd from a session with %s", (_description, prefix, sessionId) => { + const file = join(tempDir, "header.jsonl"); + const storedCwd = join(tempDir, "stored-project"); + writeSessionHeader(file, storedCwd, sessionId, prefix); + + const sessionManager = SessionManager.open(file, tempDir); + expect(sessionManager.getSessionId()).toBe(sessionId); + expect(sessionManager.getCwd()).toBe(storedCwd); + }); + + it("opens compatible sessions beyond the discovery scan limit", () => { + const storedCwd = join(tempDir, "stored-project"); + const overrideCwd = join(tempDir, "override-project"); + const cases = [ + { name: "large-header", id: "a".repeat(HEADER_SCAN_LIMIT_BYTES + 1), prefix: "" }, + { + name: "large-prefix", + id: "large-prefix", + prefix: `${"x".repeat(HEADER_SCAN_LIMIT_BYTES + 1)}\n`, + }, + ]; + + for (const { name, id, prefix } of cases) { + const file = join(tempDir, `${name}.jsonl`); + writeSessionHeader(file, storedCwd, id, prefix); + for (const cwdOverride of [undefined, overrideCwd]) { + const sessionManager = SessionManager.open(file, tempDir, cwdOverride); + expect(sessionManager.getSessionId()).toBe(id); + expect(sessionManager.getCwd()).toBe(cwdOverride ?? storedCwd); + } + } + }); + it("opens session files larger than Node's max string length", () => { const file = join(tempDir, "large.jsonl"); writeFileSync( @@ -155,6 +207,15 @@ describe("findMostRecentSession", () => { expect(findMostRecentSession(tempDir)).toBe(valid); }); + it("skips oversized corrupt files and returns a valid session", () => { + const invalid = join(tempDir, "oversized.jsonl"); + const valid = join(tempDir, "valid.jsonl"); + writeFileSync(invalid, "x".repeat(HEADER_SCAN_LIMIT_BYTES + 1)); + writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + + expect(findMostRecentSession(tempDir)).toBe(valid); + }); + it("filters most recent session by cwd", async () => { const projectA = join(tempDir, "project-a"); const projectB = join(tempDir, "project-b"); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index 2da45665968..82fc3a95dd7 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -71,7 +71,15 @@ describe("SessionManager append and tree traversal", () => { const id1 = session.appendMessage(userMsg("1")); const id2 = session.appendMessage(assistantMsg("2")); - const compactionId = session.appendCompaction("summary", id1, 1000); + const usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + const compactionId = session.appendCompaction("summary", id1, 1000, undefined, false, usage); const _id3 = session.appendMessage(userMsg("3")); const entries = session.getEntries(); @@ -83,6 +91,7 @@ describe("SessionManager append and tree traversal", () => { expect(compactionEntry.summary).toBe("summary"); expect(compactionEntry.firstKeptEntryId).toBe(id1); expect(compactionEntry.tokensBefore).toBe(1000); + expect(compactionEntry.usage).toEqual(usage); } expect(entries[3].parentId).toBe(compactionId); @@ -389,7 +398,15 @@ describe("SessionManager append and tree traversal", () => { const _id2 = session.appendMessage(assistantMsg("2")); const _id3 = session.appendMessage(userMsg("3")); - const summaryId = session.branchWithSummary(id1, "Summary of abandoned work"); + const usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + const summaryId = session.branchWithSummary(id1, "Summary of abandoned work", undefined, false, usage); expect(session.getLeafId()).toBe(summaryId); @@ -399,6 +416,7 @@ describe("SessionManager append and tree traversal", () => { expect(summaryEntry?.parentId).toBe(id1); if (summaryEntry?.type === "branch_summary") { expect(summaryEntry.summary).toBe("Summary of abandoned work"); + expect(summaryEntry.usage).toEqual(usage); } }); @@ -575,6 +593,52 @@ describe("createBranchedSession", () => { } }); + it("preserves tool and summary usage across a file-backed reload", () => { + const tempDir = join(tmpdir(), `session-usage-roundtrip-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + + try { + const session = SessionManager.create(tempDir, tempDir); + const rootId = session.appendMessage(userMsg("question")); + session.appendMessage(assistantMsg("answer")); + const usage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + session.appendMessage({ + role: "toolResult", + toolCallId: "call-1", + toolName: "nested-model", + content: [{ type: "text", text: "result" }], + isError: false, + usage, + timestamp: Date.now(), + }); + session.appendCompaction("summary", rootId, 100, undefined, false, usage); + session.branchWithSummary(rootId, "branch summary", undefined, false, usage); + + const file = session.getSessionFile(); + expect(file).toBeDefined(); + const reopened = SessionManager.open(file!, tempDir); + expect(reopened.getEntries()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "compaction", usage }), + expect.objectContaining({ type: "branch_summary", usage }), + expect.objectContaining({ + type: "message", + message: expect.objectContaining({ role: "toolResult", usage }), + }), + ]), + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("writes file immediately when forking from a point with assistant messages", () => { const tempDir = join(tmpdir(), `session-fork-with-assistant-${Date.now()}`); mkdirSync(tempDir, { recursive: true }); diff --git a/packages/coding-agent/test/suite/agent-session-compaction.test.ts b/packages/coding-agent/test/suite/agent-session-compaction.test.ts index ba5ae5c7262..33649135e2d 100644 --- a/packages/coding-agent/test/suite/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/suite/agent-session-compaction.test.ts @@ -49,7 +49,7 @@ function createAssistant( function useSummaryStreamFn(harness: Harness, summary: string): () => number { let callCount = 0; - harness.session.agent.streamFn = (model) => { + harness.session.agent.streamFunction = (model) => { callCount++; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { @@ -97,6 +97,14 @@ describe("AgentSession compaction characterization", () => { }); it("manually compacts using an extension-provided summary", async () => { + const summaryUsage = { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 100, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; const harness = await createHarness({ settings: { compaction: { keepRecentTokens: 1 } }, extensionFactories: [ @@ -106,6 +114,7 @@ describe("AgentSession compaction characterization", () => { summary: "summary from extension", firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, + usage: summaryUsage, details: { source: "extension" }, }, })); @@ -116,14 +125,26 @@ describe("AgentSession compaction characterization", () => { await harness.session.prompt("one"); await harness.session.prompt("two"); + const statsBefore = harness.session.getSessionStats(); const result = await harness.session.compact(); const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); const estimatedTokensAfter = harness.session.messages.reduce((sum, message) => sum + estimateTokens(message), 0); expect(result.summary).toBe("summary from extension"); + expect(result.usage).toEqual(summaryUsage); expect(result.estimatedTokensAfter).toBe(estimatedTokensAfter); expect(compactionEntries).toHaveLength(1); + const compactionEntry = compactionEntries[0]; + if (compactionEntry?.type === "compaction") { + expect(compactionEntry.usage).toEqual(summaryUsage); + } + const statsAfter = harness.session.getSessionStats(); + expect(statsAfter.tokens.input).toBe(statsBefore.tokens.input + summaryUsage.input); + expect(statsAfter.tokens.output).toBe(statsBefore.tokens.output + summaryUsage.output); + expect(statsAfter.tokens.cacheRead).toBe(statsBefore.tokens.cacheRead + summaryUsage.cacheRead); + expect(statsAfter.tokens.cacheWrite).toBe(statsBefore.tokens.cacheWrite + summaryUsage.cacheWrite); + expect(statsAfter.cost).toBe(statsBefore.cost + summaryUsage.cost.total); expect(harness.session.messages[0]?.role).toBe("compactionSummary"); }); @@ -154,6 +175,22 @@ describe("AgentSession compaction characterization", () => { expect(getStreamCallCount()).toBe(1); }); + it("persists usage from pi-generated manual compaction", async () => { + const harness = await createHarness({ withConfiguredAuth: false }); + harnesses.push(harness); + seedCompactableSession(harness); + useSummaryStreamFn(harness, "summary from custom stream"); + + const result = await harness.session.compact(); + + const compactionEntries = harness.sessionManager.getEntries().filter((entry) => entry.type === "compaction"); + expect(result.usage).toEqual(createUsage(10)); + expect(compactionEntries).toHaveLength(1); + expect(compactionEntries[0]?.type === "compaction" ? compactionEntries[0].usage : undefined).toEqual( + createUsage(10), + ); + }); + it("auto-compacts with a custom streamFn when registry auth is absent", async () => { const harness = await createHarness({ withConfiguredAuth: false }); harnesses.push(harness); diff --git a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts index 39a9243c1d9..b334b1232a0 100644 --- a/packages/coding-agent/test/suite/agent-session-model-extension.test.ts +++ b/packages/coding-agent/test/suite/agent-session-model-extension.test.ts @@ -1,5 +1,5 @@ import type { AgentTool, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { fauxAssistantMessage, fauxToolCall, type Model } from "@earendil-works/pi-ai"; +import { fauxAssistantMessage, fauxToolCall, type Model, type Usage } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; import type { BuildSystemPromptOptions, ExtensionAPI } from "../../src/index.ts"; @@ -156,6 +156,23 @@ describe("AgentSession model and extension characterization", () => { }); it("allows extension tool_result handlers to modify tool results", async () => { + const toolUsage: Usage = { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + totalTokens: 10, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + const patchedToolUsage: Usage = { + input: 5, + output: 6, + cacheRead: 7, + cacheWrite: 8, + totalTokens: 26, + cost: { input: 0.5, output: 0.6, cacheRead: 0.7, cacheWrite: 0.8, total: 2.6 }, + }; + let observedToolUsage: Usage | undefined; const echoTool: AgentTool = { name: "echo", label: "Echo", @@ -163,17 +180,21 @@ describe("AgentSession model and extension characterization", () => { parameters: Type.Object({ text: Type.String() }), execute: async (_toolCallId, params) => { const text = typeof params === "object" && params !== null && "text" in params ? String(params.text) : ""; - return { content: [{ type: "text", text }], details: { text } }; + return { content: [{ type: "text", text }], details: { text }, usage: toolUsage }; }, }; const harness = await createHarness({ tools: [echoTool], extensionFactories: [ (pi) => { - pi.on("tool_result", async () => ({ - content: [{ type: "text", text: "patched result" }], - details: { patched: true }, - })); + pi.on("tool_result", async (event) => { + observedToolUsage = event.usage; + return { + content: [{ type: "text", text: "patched result" }], + details: { patched: true }, + usage: patchedToolUsage, + }; + }); }, ], }); @@ -196,9 +217,12 @@ describe("AgentSession model and extension characterization", () => { await harness.session.prompt("hi"); expect(getAssistantTexts(harness)).toContain("patched result"); - expect( - harness.session.messages.find((message) => message.role === "toolResult" && message.details?.patched === true), - ).toBeDefined(); + const toolResult = harness.session.messages.find( + (message) => message.role === "toolResult" && message.details?.patched === true, + ); + expect(observedToolUsage).toEqual(toolUsage); + expect(toolResult).toBeDefined(); + expect(toolResult?.role === "toolResult" ? toolResult.usage : undefined).toEqual(patchedToolUsage); }); it("allows extension context handlers to modify messages before the LLM call", async () => { diff --git a/packages/coding-agent/test/suite/harness.ts b/packages/coding-agent/test/suite/harness.ts index a2720cf9bd3..5d254dd0a28 100644 --- a/packages/coding-agent/test/suite/harness.ts +++ b/packages/coding-agent/test/suite/harness.ts @@ -14,7 +14,7 @@ import type { FauxResponseStep, Model, } from "@earendil-works/pi-ai/compat"; -import { registerFauxProvider } from "@earendil-works/pi-ai/compat"; +import { registerFauxProvider, streamSimple } from "@earendil-works/pi-ai/compat"; import { AgentSession, type AgentSessionEvent } from "../../src/core/agent-session.ts"; import { AuthStorage } from "../../src/core/auth-storage.ts"; import type { ExtensionRunner } from "../../src/core/extensions/index.ts"; @@ -140,6 +140,7 @@ export async function createHarness(options: HarnessOptions = {}): Promise (withConfiguredAuth ? "faux-key" : undefined), + streamFunction: streamSimple, initialState: { model, systemPrompt: options.systemPrompt ?? "You are a test assistant.", diff --git a/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts b/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts index e8ef3b35457..b6efaa64831 100644 --- a/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts +++ b/packages/coding-agent/test/suite/regressions/5596-missing-theme-export.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; -import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat"; +import { fauxAssistantMessage, registerFauxProvider, streamSimple } from "@earendil-works/pi-ai/compat"; import { afterEach, describe, expect, it } from "vitest"; import { AgentSession } from "../../../src/core/agent-session.ts"; import { AuthStorage } from "../../../src/core/auth-storage.ts"; @@ -61,6 +61,7 @@ describe("regression #5596: missing configured theme export", () => { tools: [], }, convertToLlm, + streamFunction: streamSimple, }); const session = new AgentSession({ agent, diff --git a/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts b/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts index 121cc006d23..d116325d6b8 100644 --- a/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts +++ b/packages/coding-agent/test/suite/regressions/6324-branch-summary-ambient-auth.test.ts @@ -17,7 +17,7 @@ describe("issue #6324 branch summary ambient auth", () => { harnesses.push(harness); let streamCallCount = 0; - harness.session.agent.streamFn = (model, _context, options) => { + harness.session.agent.streamFunction = (model, _context, options) => { streamCallCount++; expect(options?.apiKey).toBeUndefined(); @@ -37,7 +37,7 @@ describe("issue #6324 branch summary ambient auth", () => { cacheRead: 0, cacheWrite: 0, totalTokens: 2, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.25 }, }, stopReason: "stop", timestamp: Date.now(), @@ -57,5 +57,6 @@ describe("issue #6324 branch summary ambient auth", () => { expect(streamCallCount).toBe(1); expect(result.summaryEntry?.type).toBe("branch_summary"); expect(result.summaryEntry?.summary).toContain("branch summary text"); + expect(result.summaryEntry?.usage?.cost.total).toBe(0.25); }); }); diff --git a/packages/coding-agent/test/test-harness.ts b/packages/coding-agent/test/test-harness.ts index 3dc9f628ec3..70fbf30f55f 100644 --- a/packages/coding-agent/test/test-harness.ts +++ b/packages/coding-agent/test/test-harness.ts @@ -378,7 +378,7 @@ async function createHarnessWithResourceLoader( systemPrompt: options.systemPrompt ?? "You are a test assistant.", tools: options.tools ?? [], }, - streamFn, + streamFunction: streamFn, }); const sessionManager = SessionManager.inMemory(); diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index f7568e9f210..e248450590f 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -8,7 +8,7 @@ import { homedir, tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { Agent } from "@earendil-works/pi-agent-core"; import type { OAuthCredentials } from "@earendil-works/pi-ai"; -import { getModel } from "@earendil-works/pi-ai/compat"; +import { getModel, streamSimple } from "@earendil-works/pi-ai/compat"; import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; import { AgentSession } from "../src/core/agent-session.ts"; import { AuthStorage } from "../src/core/auth-storage.ts"; @@ -246,6 +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, }); const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir); diff --git a/packages/orchestrator/CHANGELOG.md b/packages/orchestrator/CHANGELOG.md deleted file mode 100644 index 9762afa6d64..00000000000 --- a/packages/orchestrator/CHANGELOG.md +++ /dev/null @@ -1,19 +0,0 @@ -# Changelog - -## [Unreleased] - -## [0.80.10] - 2026-07-16 - -## [0.80.9] - 2026-07-16 - -## [0.80.8] - 2026-07-16 - -## [0.80.7] - 2026-07-14 - -## [0.80.6] - 2026-07-09 - -## [0.80.5] - 2026-07-09 - -## [0.80.4] - 2026-07-09 - -## [0.80.3] - 2026-06-30 diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md new file mode 100644 index 00000000000..7f82e7c32c7 --- /dev/null +++ b/packages/server/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## [Unreleased] + +## [0.81.0] - 2026-07-21 + +### Changed + +- Renamed the orchestrator workspace package and internal server references to server ([#6898](https://github.com/earendil-works/pi/pull/6898) by [@cristinaponcela](https://github.com/cristinaponcela)). + +## [0.80.10] - 2026-07-16 + +## [0.80.9] - 2026-07-16 + +## [0.80.8] - 2026-07-16 + +## [0.80.7] - 2026-07-14 + +## [0.80.6] - 2026-07-09 + +## [0.80.5] - 2026-07-09 + +## [0.80.4] - 2026-07-09 + +## [0.80.3] - 2026-06-30 diff --git a/packages/orchestrator/README.md b/packages/server/README.md similarity index 67% rename from packages/orchestrator/README.md rename to packages/server/README.md index aabd724365b..2e8e877b75d 100644 --- a/packages/orchestrator/README.md +++ b/packages/server/README.md @@ -1,11 +1,11 @@ -# @earendil-works/pi-orchestrator +# @earendil-works/pi-server Experimental. This package is under active development and may change or be removed without notice. Its CLI, APIs, and behavior are not yet stable. -Orchestrator package for pi. +Server package for pi. ## CLI ```bash -orchestrator --help +server --help ``` diff --git a/packages/orchestrator/package.json b/packages/server/package.json similarity index 74% rename from packages/orchestrator/package.json rename to packages/server/package.json index 39c8218dc98..907bd0002a8 100644 --- a/packages/orchestrator/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { - "name": "@earendil-works/pi-orchestrator", - "version": "0.80.10-kkl.1", - "description": "experimental orchestrator package for pi", + "name": "@earendil-works/pi-server", + "version": "0.81.0-kkl.1", + "description": "experimental server package for pi", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -11,6 +11,9 @@ "import": "./dist/index.js" } }, + "bin": { + "server": "./dist/cli.js" + }, "files": [ "dist", "README.md", @@ -24,20 +27,20 @@ }, "keywords": [ "pi", - "orchestrator" + "server" ], "author": "Earendil Works", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/earendil-works/pi.git", - "directory": "packages/orchestrator" + "directory": "packages/server" }, "engines": { "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.10-kkl.1" + "@earendil-works/pi-coding-agent": "0.81.0-kkl.1" }, "devDependencies": { "shx": "0.4.0" diff --git a/packages/orchestrator/src/cli.ts b/packages/server/src/cli.ts similarity index 84% rename from packages/orchestrator/src/cli.ts rename to packages/server/src/cli.ts index 856dd658407..738d416edc2 100644 --- a/packages/orchestrator/src/cli.ts +++ b/packages/server/src/cli.ts @@ -18,7 +18,7 @@ const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), function printHelp(): void { console.log( - `orchestrator v${packageJson.version}\n\nUsage:\n orchestrator serve\n orchestrator list\n orchestrator spawn [--cwd ] [--label