diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c82c1ee..de807fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,35 @@ permissions: contents: read jobs: + lint: + name: Lint and type-check + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - name: Install dependencies + run: npm ci + - name: Lint + run: npm run lint + - name: Type-check + run: npm run typecheck + - name: Verify package contents + run: npm pack --dry-run + test: name: Node.js ${{ matrix.node-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + # macOS is the primary developer platform and the one whose paths, + # shell profile handling and node:sqlite build differ from Linux. + os: [ubuntu-latest, macos-latest, windows-latest] node-version: [20, 22, 24] steps: - name: Check out repository @@ -33,5 +55,3 @@ jobs: run: npm ci - name: Run tests run: npm test - - name: Verify package contents - run: npm pack --dry-run diff --git a/.gitignore b/.gitignore index d34ad54..fba155c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ node_modules/ dist/ .claude/ +.DS_Store +*.tsbuildinfo +.env +.env.* +coverage/ +.worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 9b65702..7ed3f01 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,10 +12,12 @@ npm ci # 按 package-lock.json 安装精确依赖 npm run build # 编译 TypeScript 到 dist/(使用 tsc) npm test # 先构建,再执行 node --test(在 dist/ 下自动发现测试文件;Windows 的 cmd.exe 不做 glob 展开,故用目录自动发现而非 dist/test/*.test.js) +npm run lint # ESLint(flat config,见 eslint.config.js) +npm run typecheck # tsc --noEmit,同时检查 src/ 与 test/ ``` -运行单个测试文件:`npm run build && node --test dist/test/catalog.test.js` -运行完整的本地 CI 检查(安装 + 测试 + 包校验):`make check` +运行单个测试文件:`npm run test:one -- dist/test/catalog.test.js`(或 `make test-one F=test/catalog`) +运行完整的本地 CI 检查(安装 + 测试 + lint + 包校验):`make check` `Makefile` 提供了一些便捷目标,运行 `make` 可查看完整列表。常见的有:`make doctor`、`make test`、`make build`、`make version`。 @@ -23,6 +25,8 @@ npm test # 先构建,再执行 node --test(在 dist/ 下自动发 本包声明了 `"type": "module"` 并使用 `rootDir: "."` 编译,因此**所有 import 都使用 `.js` 后缀,且所有路径以仓库根目录为基准**(例如 `import { loadConfig } from "./config.js"`,测试位于 `test/` 下,导入如 `"../src/catalog.js"`)。 +`src/` 中不使用 `any`:客户端请求、Provider 响应与 SSE 事件这类无法预先验证的 JSON 一律建模为 `src/json.ts` 的 `JsonRecord`,通过 `recStr`/`recNum`/`recCount`/`recObj`/`recObjs`/`parse` 读取。字段名写错或漏掉非对象检查会直接编译失败,而不是变成一次运行时 undefined。tsconfig 开启了 `noUncheckedIndexedAccess` 等严格标志,ESLint 只保留 tsc 看不见的规则(见 `eslint.config.js`)。 + ### 两套协议转换路径 适配器对外暴露三个请求端点,在三种 API 格式之间进行转换。本地面向客户端(client-facing)的端点是: @@ -34,7 +38,7 @@ npm test # 先构建,再执行 node --test(在 dist/ 下自动发 转换函数集中在 `src/convert/`,按**上游协议**(而非转换方向)拆分为四个文件: -- `shared.ts` — 跨方向 helper:图片/effort/thinking/三套 tool-choice 转换、采样参数、JSON 解析等 +- `shared.ts` — 跨方向 helper:图片/effort/thinking/三套 tool-choice 转换、采样参数(JSON 字段的读取统一走 `src/json.ts`) - `chat.ts` — 上游 = Chat Completions 的全部方向:`toChatRequest` / `fromChatResponse`(Anthropic ↔ Chat Completions)、`toChatCompletionsRequest` / `fromChatResponseToResponses`(Responses ↔ Chat Completions) - `responses.ts` — 上游 = Responses:`toResponsesRequest` / `fromResponsesResponse`(Anthropic ↔ Responses);`toResponsesRequestFromChat` / `fromResponsesResponseToChat`(本地 Chat Completions ↔ Responses;分别复用 `toResponsesRequest`/`fromResponsesResponse` 加 `anthropic.ts` 的两个新函数拼出,不重复写转换逻辑) - `anthropic.ts` — 上游 = Anthropic(自定义 Provider 声明 `protocol: "anthropic"` 时专属):`toAnthropicRequest` / `fromAnthropicResponse`(Responses ↔ Anthropic;Codex 只会看到本地 Responses 端点,仍需经此转换才能到达一个原生 Anthropic 上游);`toAnthropicRequestFromChat` / `fromAnthropicResponseToChat`(本地 Chat Completions ↔ Anthropic,供 `/v1/chat/completions` 使用,只映射主流字段,不映射 DeepSeek 的 `thinking`/`reasoning_effort` 扩展) diff --git a/Makefile b/Makefile index 62b9581..2a9a0c7 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ CLI := node dist/src/cli.js .DEFAULT_GOAL := help -.PHONY: help install build test check ci pack install-local link unlink \ +.PHONY: help install build test test-one lint typecheck check ci pack install-local link unlink \ claude codex proxy doctor version clean help: ## Show available targets @@ -26,10 +26,21 @@ build: node_modules/.package-lock.json ## Compile TypeScript into dist/ test: node_modules/.package-lock.json ## Build and run the test suite $(NPM) test -check: test ## Run tests and verify the npm package contents +# make test-one F=test/streaming — a single test file, quoted so a bare +# `make test-one` doesn't expand to every file in the directory. +test-one: node_modules/.package-lock.json ## Build and run one test file (F=test/) + $(NPM) run test:one -- "dist/$(F).test.js" + +lint: node_modules/.package-lock.json ## Lint the repository + $(NPM) run lint + +typecheck: node_modules/.package-lock.json ## Type-check src/ and test/ without emitting + $(NPM) run typecheck + +check: test lint ## Run tests, lint and verify the npm package contents $(NPM) pack --dry-run -ci: install test ## Reproduce the GitHub Actions verification locally +ci: install lint test ## Reproduce the GitHub Actions verification locally pack: build ## Build and create an npm tarball $(NPM) pack diff --git a/README.md b/README.md index ccc45a5..77525fe 100644 --- a/README.md +++ b/README.md @@ -511,16 +511,21 @@ git clone https://github.com/tzzs/agentx.git cd agentx npm ci npm test +npm run lint npm run build ``` The project uses TypeScript, native Node.js `fetch`, Node.js ESM, and the built-in `node:test` runner. Tests are compiled into `dist/test` before execution. +`npm run lint` runs ESLint (flat config in `eslint.config.js`), `npm run typecheck` runs `tsc --noEmit` over `src/` and `test/`, and `make check` runs both plus the package check. `src/` is free of `any`: wire payloads are read through the typed accessors in `src/json.ts`. + +To run one test file: `npm run test:one -- dist/test/catalog.test.js` (or `make test-one F=test/catalog`). + The test suite covers request/response conversion, system instructions, streaming events, tool calls, provider routing, chat-completion conversion, token usage adapters, storage, and the usage query API. Tests do not require an API key or network access. ## CI and Publishing -GitHub Actions runs the build, tests, and package dry-run for every push and pull request against `main`. Release Please is configured in `.github/workflows/release-please.yml` and creates a release PR from conventional commits. Publishing is configured in `.github/workflows/publish.yml`: +GitHub Actions runs lint and type-check once per push, and the test suite on Node.js 20/22/24 across Linux, macOS and Windows, for every push and pull request against `main`. Release Please is configured in `.github/workflows/release-please.yml` and creates a release PR from conventional commits. Publishing is configured in `.github/workflows/publish.yml`: 1. Add an `NPM_TOKEN` secret to the `npm` GitHub environment. 2. Push a tag matching `v*.*.*`, or manually run **Publish package**. @@ -538,7 +543,7 @@ Install Claude Code and ensure `claude` is available in the same shell's `PATH`, **`Codex not found: the "codex" command is not installed or not on PATH`** -When a client executable is missing, AgentX explains the problem and prints the recommended install command (for example, `npm install -g @openai/codex`). In an interactive terminal it also offers to run that command for you and relaunches the client after a verified install. Decline to install manually; re-run the same `agentx ` command afterwards. +When a client executable is missing, AgentX explains the problem and prints the recommended install command (for example, `npm install -g @openai/codex`), then exits. AgentX never installs clients for you — run the command yourself, then re-run the same `agentx ` command. **The port is busy** diff --git a/README.zh-CN.md b/README.zh-CN.md index 71f6b09..63fe9d9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -496,16 +496,21 @@ git clone https://github.com/tzzs/agentx.git cd agentx npm ci npm test +npm run lint npm run build ``` 项目使用 TypeScript、Node.js 原生 `fetch`、Node.js ESM 和内置 `node:test` 测试运行器。测试会先编译到 `dist/test`,再执行编译后的测试。 +`npm run lint` 运行 ESLint(flat config 位于 `eslint.config.js`),`npm run typecheck` 对 `src/` 与 `test/` 执行 `tsc --noEmit`,`make check` 则会连同包校验一起跑完这些检查。`src/` 中不使用 `any`:wire payload 统一通过 `src/json.ts` 的带类型访问器读取。 + +只跑单个测试文件:`npm run test:one -- dist/test/catalog.test.js`(或 `make test-one F=test/catalog`)。 + 测试覆盖请求/响应转换、system instructions、流式事件、工具调用、Provider 路由、Chat Completions 转换,以及 Token 用量适配器、存储和用量查询 API。测试不需要 API Key,也不依赖网络。 ## CI 与发布 -GitHub Actions 会在每次 push 和针对 `main` 的 Pull Request 中运行构建、测试和 npm 包内容检查。`.github/workflows/release-please.yml` 会根据 Conventional Commits 创建版本发布 PR。发布配置位于 `.github/workflows/publish.yml`: +GitHub Actions 会在每次 push 和针对 `main` 的 Pull Request 中运行一次 lint 与类型检查,并在 Linux、macOS、Windows 三套系统的 Node.js 20/22/24 矩阵上跑测试。`.github/workflows/release-please.yml` 会根据 Conventional Commits 创建版本发布 PR。发布配置位于 `.github/workflows/publish.yml`: 1. 在 GitHub 的 `npm` environment 中添加 `NPM_TOKEN` Secret。 2. 推送匹配 `v*.*.*` 的版本标签,或手动运行 **Publish package** 工作流。 @@ -523,7 +528,7 @@ GitHub Actions 会在每次 push 和针对 `main` 的 Pull Request 中运行构 **`Codex not found: the "codex" command is not installed or not on PATH`** -当客户端可执行文件缺失时,AgentX 会说明问题并给出推荐的安装命令(例如 `npm install -g @openai/codex`)。在交互式终端中还会询问是否立即执行该命令,并在确认安装成功后自动重新启动客户端。也可以选择跳过、手动安装,之后再次运行相同的 `agentx ` 命令。 +当客户端可执行文件缺失时,AgentX 会说明问题并给出推荐的安装命令(例如 `npm install -g @openai/codex`),然后退出。AgentX 不会代为执行安装——请自行运行该命令,之后再次运行相同的 `agentx ` 命令。 **端口被占用** diff --git a/docs/architecture.md b/docs/architecture.md index d885fc2..8d5b318 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -73,7 +73,9 @@ Claude Code (Anthropic Messages API) └─ 流式: streaming/anthropic-passthrough.ts, streaming/anthropic-to-responses.ts ``` -`src/convert/` 是转换函数所在的模块化目录:`shared.ts`(跨方向 helper:图片/effort/thinking/tool-choice 映射、采样参数、JSON 解析)、`chat.ts`(上游 = Chat Completions 的全部方向)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel 导出,公共函数名不变)。`src/catalog.ts` 只保留 `providers`/`providerFor`/`honorRequestedModel` 路由函数。 +`src/convert/` 是转换函数所在的模块化目录:`shared.ts`(跨方向 helper:图片/effort/thinking/tool-choice 映射、采样参数)、`chat.ts`(上游 = Chat Completions 的全部方向)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel 导出,公共函数名不变)。`src/catalog.ts` 只保留 `providers`/`providerFor`/`honorRequestedModel` 路由函数。 + +所有 wire payload(客户端请求、Provider 响应、SSE 事件)经 `src/json.ts` 的 `JsonRecord` + 字段访问器(`recStr`/`recNum`/`recCount`/`recObj`/`recObjs`/`parse`)读取,代码库里不使用 `any`:字段名写错或漏掉非对象检查由编译器拦下,而不是留成一次 undefined 运行时错误。 `src/streaming/` 同样是拆分后的模块化目录(`common.ts` 收敛公共 SSE 写入/heartbeat/usage capture 逻辑,每条协议转换路径各占一个文件),不是单一的 `streaming.ts`。 @@ -119,7 +121,10 @@ Claude Code (Anthropic Messages API) | `src/server.ts` | HTTP Adapter、认证、路由、重试、端口回退 | | `src/catalog.ts` | 模型路由(`providers`/`providerFor`/`honorRequestedModel`),不含转换函数 | | `src/convert/` | 协议转换函数,按上游协议拆分:`shared.ts`(跨方向 helper)、`chat.ts`(上游 = Chat Completions)、`responses.ts`(上游 = Responses)、`anthropic.ts`(上游 = Anthropic,自定义 Provider 专属)、`index.ts`(barrel) | +| `src/json.ts` | wire payload 的类型词汇表:`JsonRecord` 与字段访问器,替代 `any` | | `src/streaming/` | SSE 流式协议转换(按上游协议拆分成多个文件) | +| `src/fsutil.ts` | `atomicWriteFile`:临时文件 + rename,避免崩溃留下截断的状态文件 | +| `src/usage/` | `TokenUsage` 类型、存储后端(sqlite/json/memory)、统计渲染 | | `src/providers/registry.ts` + `types.ts` | Provider 目录、模型注册表、自定义 Provider 注册/移除 | | `src/process.ts` | 子进程启动/环境注入/stdio 转发/清理/`--native` 环境清洗 | | `src/credentials.ts` | 凭据查找(CLI > `AGENTX_` 前缀环境变量 > 旧环境变量 > 交互输入) | @@ -127,4 +132,4 @@ Claude Code (Anthropic Messages API) | `src/quota.ts` | Provider 远程额度查询(`agentx quota`) | | `src/doctor.ts` / `src/ui.ts` | 诊断 / 交互式启动器(含自定义 Provider 的 Add/Remove 流程) | -> 注:相比 agentx.md 中建议的 `src/commands/`、`src/proxy/`、`src/runtime/` 目录结构,实际实现是扁平化的 `src/*.ts`(server.ts 取代了 proxy/anthropic/responses/chat-completions/auth 分层,转换逻辑集中在 `src/convert/`,按上游协议而非方向拆分)。 +> 注:相比 `docs/plans/initial-product-spec.md` 中建议的 `src/commands/`、`src/proxy/`、`src/runtime/` 目录结构,实际实现是扁平化的 `src/*.ts`(server.ts 取代了 proxy/anthropic/responses/chat-completions/auth 分层,转换逻辑集中在 `src/convert/`,按上游协议而非方向拆分)。 diff --git a/agentx.md b/docs/plans/initial-product-spec.md similarity index 98% rename from agentx.md rename to docs/plans/initial-product-spec.md index af26517..1601f9d 100644 --- a/agentx.md +++ b/docs/plans/initial-product-spec.md @@ -1,4 +1,10 @@ -# 项目:agentx +# Plan: AgentX 初始产品规格 + +Status: Shipped — 这是项目从零开始时的规格说明,保留作为设计初衷的历史记录; +与现状的偏差(命令集、目录结构、provider 范围)以 `docs/architecture.md` 为准。 +Date: 2026-08-18 + +## 原始规格 请实现一个生产可用的 npm CLI 工具 **`agentx`**。 diff --git a/docs/remaining-simplification-todos.md b/docs/remaining-simplification-todos.md index a55c212..a40faf1 100644 --- a/docs/remaining-simplification-todos.md +++ b/docs/remaining-simplification-todos.md @@ -1,7 +1,7 @@ # AgentX 剩余精简待办 -> 更新时间:2026-08-30 -> 背景:已完成 runtime/profile 状态合并、Codex catalog 收窄、移除 `--model auto` 隐式路由、模型目录按需刷新、凭据 shell profile 自动写入移除、`doctor` 按 client/`--offline` 范围控制、`streaming.ts` 拆分为模块化目录、cost estimation 删除、`agentx quota` 从 `usage` 拆分、未接入的 Google usage/pricing 及整个 capabilities 抽象删除。本文档记录后续建议继续执行的精简项——已完成的条目直接从列表移除,不在此保留变更记录(改动历史见 git log)。 +> 更新时间:2026-09-20 +> 背景:已完成 runtime/profile 状态合并、Codex catalog 收窄、移除 `--model auto` 隐式路由、模型目录按需刷新、凭据 shell profile 自动写入移除、`doctor` 按 client/`--offline` 范围控制、`streaming.ts` 拆分为模块化目录、cost estimation 删除、`agentx quota` 从 `usage` 拆分、未接入的 Google usage/pricing 及整个 capabilities 抽象删除、客户端自动安装流程移除(改为纯提示)。本文档记录后续建议继续执行的精简项——已完成的条目直接从列表移除,不在此保留变更记录(改动历史见 git log)。 > > 同一批改造里还新增了自定义 Provider(含原生 Anthropic Messages API 协议支持)。这是功能增补,不是本文档要精简的对象;`Anthropic` 的 usage 适配器(`src/providers/usage/anthropic.ts`)因此从死代码变为活代码,不再适用于"未接入协议应删除"的判断。 @@ -9,27 +9,8 @@ | 级别 | 待办 | 主要收益 | | --- | --- | --- | -| G | 删除客户端自动安装流程 | 缩小 launcher 职责 | | K | 同账号多实例请求协调 | 减少并发排队造成的感知延迟 | -## G. 删除客户端自动安装流程 - -找不到 `claude` / `codex` 时,launcher 会交互式询问是否执行全局 npm 安装。这个功能方便,但会让 AgentX 额外承担外部安装器的职责,也增加错误处理路径。 - -### 建议方案 - -退化为清晰的提示: - -```bash -✗ Codex not found. -Install: - npm install -g @openai/codex -Then re-run: - agentx codex -``` - -保留 `doctor` 检测客户端是否可用即可。 - --- ## K. 同账号多实例请求协调 @@ -48,4 +29,4 @@ Then re-run: ## 建议下一批执行顺序 -G 和 H 都是需要用户拍板的方向性选择(是保留便利功能、收窄支持范围,还是移除),不建议在没有明确意见的情况下单方面执行。K 已经结论为"暂不实施",除非诊断层面的轻量方案被明确需要。 +K 是唯一剩余的待办,且已结论为"暂不实施",除非诊断层面的轻量方案被明确需要。 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5f1292c --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,27 @@ +import tseslint from "typescript-eslint"; + +/** + * The type checker carries most of the safety here (strict mode plus + * noUncheckedIndexedAccess), so ESLint keeps only what tsc can't see: an + * accidental `any` reintroduced at a wire boundary, and dead code. + */ +export default tseslint.config( + { ignores: ["dist/**", "node_modules/**"] }, + ...tseslint.configs.recommended, + { + files: ["**/*.ts"], + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": ["error", { args: "all", argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + eqeqeq: ["error", "always", { null: "ignore" }], + "no-var": "error", + "prefer-const": "error", + }, + }, + { + // Tests stub providers with loosely-shaped JSON, and an assertion's shape + // is exactly what a test is allowed to leave imprecise. + files: ["test/**/*.ts"], + rules: { "@typescript-eslint/no-explicit-any": "off" }, + }, +); diff --git a/package-lock.json b/package-lock.json index 3e50d7e..f24b02b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,12 +16,38 @@ }, "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.6.0" + "eslint": "^10.11.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0" }, "engines": { "node": ">=20" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@clack/core": { "version": "1.4.3", "resolved": "https://registry.npmmirror.com/@clack/core/-/core-1.4.3.tgz", @@ -47,19 +73,773 @@ "sisteransi": "^1.0.5" }, "engines": { - "node": ">= 20.12.0" + "node": ">= 20.12.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmmirror.com/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -84,12 +864,445 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmmirror.com/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -104,12 +1317,85 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 4d50d63..227eece 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,11 @@ }, "scripts": { "build": "tsc", - "test": "npm run build && cd dist && node --test" + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "npm run build && cd dist && node --test", + "test:one": "npm run build && node --test" }, "files": [ "dist/src", @@ -30,7 +34,9 @@ ], "devDependencies": { "@types/node": "^22.0.0", - "typescript": "^5.6.0" + "eslint": "^10.11.0", + "typescript": "^5.6.0", + "typescript-eslint": "^8.70.0" }, "dependencies": { "@clack/prompts": "^1.7.0" diff --git a/src/catalog.ts b/src/catalog.ts index 7dce295..fe8f86a 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,7 +1,7 @@ import { allModels, providerFor as resolveProvider } from "./providers/registry.js"; import type { ProviderModel } from "./providers/types.js"; -export type ModelProvider = ProviderModel; +type ModelProvider = ProviderModel; export const providers = allModels; export function providerFor(model: string, provider?: string): ModelProvider { return resolveProvider(model, provider); } diff --git a/src/cli.ts b/src/cli.ts index 041e709..5de2d2d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,11 +3,11 @@ import { readFileSync, realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { CLAUDE_EFFORT_LEVELS, CODEX_EFFORT_LEVELS, loadConfig, parseCliOptions as options, parseHeaderFlags } from "./config.js"; import { startAdapter } from "./server.js"; -import { runCommand, runShellCommand, ClientNotFoundError, CLIENT_INSTALL_COMMANDS, clientEnvironment, codexLaunchArgs, nativeClientEnvironment } from "./process.js"; +import { runCommand, ClientNotFoundError, CLIENT_INSTALL_COMMANDS, clientEnvironment, codexLaunchArgs, nativeClientEnvironment } from "./process.js"; import { providerEntries, runInteractiveLauncher, runProviderManager, runSavedModelManager, LaunchCancelledError, type ProviderEntry } from "./ui.js"; -import { credentialEnvName, providerById, refreshProviderCatalog, fetchOpenRouterModels, hydrateOpenRouterCatalog, openRouterCatalogIds, providerDisplayName, registerCustomProvider, unregisterCustomProvider } from "./providers/registry.js"; +import { credentialEnvName, providerById, refreshProviderCatalog, fetchOpenRouterModels, hydrateOpenRouterCatalog, openRouterCatalogIds, providerDisplayName, registerCustomProvider, unregisterCustomProvider, type CustomProviderDefinition } from "./providers/registry.js"; import type { ProviderDefinition, ProviderProtocol } from "./providers/types.js"; -import { runDoctor, renderDoctor, executableExists } from "./doctor.js"; +import { runDoctor, renderDoctor } from "./doctor.js"; import { credentialInstructions, credentialSource, hydrateProfileCredentials, promptCredential, resolveCredential } from "./credentials.js"; import { runQuotaCommand } from "./quota.js"; import { runUsageStats } from "./usage/cli.js"; @@ -15,7 +15,6 @@ import { resolveRuntimeNonInteractive } from "./selection.js"; import { forgetCustomProvider, loadCustomProviders, loadLastSelection, loadSessionRecord, remembererProviders, rememberedModelIds, saveCustomProvider, saveLastModel, saveOpenRouterModels, saveSessionRecord } from "./runtime.js"; import { catalogModels, writeCodexCatalog } from "./codex-catalog.js"; import { applySessionRecord, discoverClaudeSessionId, discoverCodexSessionId, resumeSessionId } from "./sessions.js"; -import { confirm, isCancel } from "@clack/prompts"; const HELP: Record = { claude: "Start the local adapter and Claude Code together", @@ -150,7 +149,7 @@ const BOOLEAN_ADAPTER_FLAGS = new Set(["--verbose", "--native"]); export function clientArguments(args: string[]): string[] { const isAdapterFlag = (arg: string) => ADAPTER_FLAGS.has(arg) || (arg.startsWith("--") && arg.includes("=") && ADAPTER_FLAGS.has(`--${arg.slice(2).split("=", 1)[0]}`)); const valueTaken = new Set(); - args.forEach((arg, index) => { if (ADAPTER_FLAGS.has(arg) && !BOOLEAN_ADAPTER_FLAGS.has(arg) && args[index + 1] !== undefined && !args[index + 1].startsWith("--")) valueTaken.add(index + 1); }); + args.forEach((arg, index) => { const next = args[index + 1]; if (ADAPTER_FLAGS.has(arg) && !BOOLEAN_ADAPTER_FLAGS.has(arg) && next !== undefined && !next.startsWith("--")) valueTaken.add(index + 1); }); return args.filter((arg, index) => !isAdapterFlag(arg) && !valueTaken.has(index)); } @@ -238,48 +237,24 @@ function reportMissingClient(executable: string): void { } } -/** Injectable side effects for {@link launchClient}, so tests can exercise the install-recovery flow without a real terminal, shell, or spawned process. */ +/** Injectable side effects for {@link launchClient}, so tests can swap in a fake spawn without a real terminal or process. */ export interface ClientLaunchDeps { runCommand?: typeof runCommand; - confirm?: typeof confirm; - runShellCommand?: typeof runShellCommand; - executableExists?: typeof executableExists; } /** - * Launch a client, with a recovery path for a missing executable: explain the - * problem, and — interactively only — offer to run the known install command. - * After a confirmed, successful install (verified by spawning the binary), - * retry the launch; otherwise exit with a clear message. The adapter stays up - * throughout so a successful install flows straight into the client session. + * Launch a client. A missing executable is reported with the recommended + * install command and exit code 1 — AgentX never installs clients itself. */ export async function launchClient(executable: string, args: string[], env: NodeJS.ProcessEnv, deps: ClientLaunchDeps = {}): Promise { const run = deps.runCommand ?? runCommand; - const confirmFn = deps.confirm ?? confirm; - const runShell = deps.runShellCommand ?? runShellCommand; - const exists = deps.executableExists ?? executableExists; try { return await run(executable, args, env); } catch (error) { if (!(error instanceof ClientNotFoundError)) throw error; reportMissingClient(executable); - const installCommand = CLIENT_INSTALL_COMMANDS[executable]; - if (!isInteractive() || !installCommand) { process.exitCode = 1; return 1; } - const proceed = await confirmFn({ message: `Run \`${installCommand}\` now?`, initialValue: false }); - if (isCancel(proceed) || !proceed) { - console.error(`Skipped installation. Re-run agentx ${executable} once installed.`); - process.exitCode = 1; - return 1; - } - console.error(`Running: ${installCommand}`); - const code = await runShell(installCommand); - if (code !== 0 || !(await exists(executable))) { - console.error("✗ Installation did not complete or the executable is still missing.\n Install it manually, then re-run this command."); - process.exitCode = 1; - return 1; - } - console.error(`✓ ${CLIENT_LABELS[executable] ?? executable} installed`); - return run(executable, args, env); + process.exitCode = 1; + return 1; } } @@ -448,7 +423,7 @@ function resolveLaunchTarget(command: string, args: string[], opts: Record, headers: Record = {}): Promise { +async function persistCustomProvider(opts: Record, headers: Record = {}): Promise { const baseUrl = opts["base-url"] ?? ""; const protocol: ProviderProtocol = opts.protocol === "responses" || opts.protocol === "anthropic" ? opts.protocol : "chat-completions"; const definition = registerCustomProvider({ name: opts.provider ?? "custom", baseUrl, protocol, model: opts.model, headers }); @@ -461,7 +436,7 @@ async function persistCustomProvider(opts: Record, h * resolution (interactive or not), native-launch bypass, credential * resolution with a missing-key recovery flow, adapter startup, and finally * handing off to {@link launchClient}. `deps` only affects the final - * `launchClient` call, so tests can exercise the install-recovery flow. + * `launchClient` call, so tests can substitute the spawn. */ export async function runClientLaunch(command: string, args: string[], deps: ClientLaunchDeps = {}): Promise { let opts = options(args); diff --git a/src/config.ts b/src/config.ts index 2f98651..2cf3579 100644 --- a/src/config.ts +++ b/src/config.ts @@ -42,7 +42,7 @@ export function parseCliOptions(args: string[]): Record { const add = (raw: string | undefined) => { if (!raw) return; const match = /^\s*([^=:]+?)\s*[=:]\s*(.*)$/.exec(raw); - if (!match) return; - headers[match[1]] = match[2]; + const key = match?.[1]; const value = match?.[2]; + if (key === undefined || value === undefined) return; + headers[key] = value; }; for (let i = 0; i < args.length; i++) { const arg = args[i]; diff --git a/src/convert/anthropic.ts b/src/convert/anthropic.ts index 950ab71..9ecda0e 100644 --- a/src/convert/anthropic.ts +++ b/src/convert/anthropic.ts @@ -4,102 +4,109 @@ * Responses endpoint) still needs to reach it. Unlike responses.ts, here the * upstream is Anthropic-shaped. */ -import type { AnthropicMessage, AnthropicThinking } from "./shared.js"; -import { anthropicThinking, anthropicToolChoice, parse } from "./shared.js"; - -/** Build an Anthropic image content block from a Responses `input_image.image_url`, which may be a real URL or a data: URI. */ -function toAnthropicImageSource(url: string): { type: "url"; url: string } | { type: "base64"; media_type: string; data: string } | undefined { - const dataMatch = /^data:([^;]+);base64,(.+)$/.exec(url); - if (dataMatch) return { type: "base64", media_type: dataMatch[1], data: dataMatch[2] }; - return url ? { type: "url", url } : undefined; -} +import type { AnthropicMessage, AnthropicRequest, AnthropicThinking } from "./shared.js"; +import type { JsonRecord, JsonValue } from "../json.js"; +import { asRecords, isRecord, parse, recNum, recObj, recObjs, recStr } from "../json.js"; +import { anthropicThinking, anthropicToolChoice, collapseAnthropicContent, textOfBlocks, toAnthropicImageSource } from "./shared.js"; /** Responses message content (string or input_text/input_image parts) to Anthropic message content. */ -function toAnthropicContent(content: any): any { +function toAnthropicContent(content: JsonValue): JsonValue { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; - const parts = content.map((part: any) => { - if (part?.type === "input_text" || part?.type === "output_text") return part.text ? { type: "text", text: part.text } : undefined; - if (part?.type === "input_image" && typeof part.image_url === "string") { const source = toAnthropicImageSource(part.image_url); return source ? { type: "image", source } : undefined; } - return undefined; - }).filter((part: any) => part !== undefined); - if (!parts.length) return ""; - return parts.every((part: any) => part.type === "text") ? parts.map((part: any) => part.text).join("") : parts; + const parts: JsonRecord[] = []; + for (const raw of content) { + if (!isRecord(raw)) continue; + const text = recStr(raw, "text"); + if (raw.type === "input_text" || raw.type === "output_text") { if (text) parts.push({ type: "text", text }); continue; } + const url = recStr(raw, "image_url"); + if (raw.type === "input_image" && url) { const source = toAnthropicImageSource(url); if (source) parts.push({ type: "image", source }); } + } + return collapseAnthropicContent(parts); } /** Plain text of a Responses message item's content, ignoring non-text parts. */ -function responsesItemText(content: any): string { +function responsesItemText(content: JsonValue): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; - return content.filter((part: any) => part?.type === "output_text" || part?.type === "input_text").map((part: any) => part.text ?? "").join(""); + return content.filter(isRecord) + .filter((part) => part.type === "output_text" || part.type === "input_text") + .map((part) => recStr(part, "text") ?? "").join(""); } -export function toAnthropicRequest(input: any, model: string): Record { - const items = typeof input.input === "string" ? [{ role: "user", content: input.input }] : input.input ?? []; +export function toAnthropicRequest(input: JsonRecord, model: string): AnthropicRequest { + const rawInput = input.input; + const items: JsonRecord[] = typeof rawInput === "string" ? [{ role: "user", content: rawInput }] : asRecords(rawInput); const messages: AnthropicMessage[] = []; // Responses represents one assistant turn as separate top-level items // (a message item for text, a function_call item per tool call); Anthropic // wants them merged back into one assistant message's content blocks. - let pendingAssistantBlocks: any[] = []; + let pendingAssistantBlocks: JsonRecord[] = []; const flushAssistant = () => { if (pendingAssistantBlocks.length) { messages.push({ role: "assistant", content: pendingAssistantBlocks }); pendingAssistantBlocks = []; } }; for (const item of items) { if (item.type === "reasoning") continue; // no signature to echo upstream; dropped like other local-only reasoning echoes - if (item.type === "function_call") { pendingAssistantBlocks.push({ type: "tool_use", id: item.call_id ?? item.id, name: item.name, input: parse(item.arguments) }); continue; } + if (item.type === "function_call") { pendingAssistantBlocks.push({ type: "tool_use", id: recStr(item, "call_id") ?? recStr(item, "id"), name: recStr(item, "name"), input: parse(item.arguments) }); continue; } if (item.type === "function_call_output") { flushAssistant(); messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? "") }] }); continue; } if (item.role === "assistant") { const text = responsesItemText(item.content); if (text) pendingAssistantBlocks.push({ type: "text", text }); continue; } - if (item.role) { flushAssistant(); messages.push({ role: item.role === "developer" ? "user" : item.role, content: toAnthropicContent(item.content) }); } + if (typeof item.role === "string") { flushAssistant(); messages.push({ role: item.role === "developer" ? "user" : item.role, content: toAnthropicContent(item.content) }); } } flushAssistant(); // Anthropic requires max_tokens > thinking.budget_tokens (and budget_tokens // >= 1024), so the tiered default budgets are capped to leave visible-output // headroom; a request too small to think at all drops the block entirely. - const maxTokens = input.max_output_tokens ?? 4096; - let thinking: AnthropicThinking | undefined = anthropicThinking(input.reasoning?.effort); + const maxTokens = recNum(input, "max_output_tokens") ?? 4096; + let thinking: AnthropicThinking | undefined = anthropicThinking(recStr(recObj(input, "reasoning"), "effort")); if (thinking?.type === "enabled") { const budget = Math.min(thinking.budget_tokens ?? 0, Math.floor(maxTokens * 0.8)); thinking = budget >= 1024 ? { type: "enabled", budget_tokens: budget } : undefined; } const toolChoice = anthropicToolChoice(input.tool_choice); - const tools = Array.isArray(input.tools) - ? input.tools.filter((tool: any) => typeof tool?.name === "string").map((tool: any) => ({ name: tool.name, ...(tool.description === undefined ? {} : { description: tool.description }), input_schema: tool.parameters ?? { type: "object", properties: {} } })) - : undefined; + const instructions = recStr(input, "instructions"); + const temperature = recNum(input, "temperature"); + const top_p = recNum(input, "top_p"); + const tools = asRecords(input.tools).flatMap((tool) => { + const name = recStr(tool, "name"); + if (!name) return []; + const description = recStr(tool, "description"); + return [{ name, ...(description === undefined ? {} : { description }), input_schema: tool.parameters ?? { type: "object", properties: {} } }]; + }); return { model, messages, // Anthropic requires max_tokens; a Responses caller that omits max_output_tokens still needs a concrete value sent upstream. max_tokens: maxTokens, - ...(input.instructions ? { system: input.instructions } : {}), + ...(instructions ? { system: instructions } : {}), ...(input.stream ? { stream: true } : {}), - ...(input.temperature === undefined ? {} : { temperature: input.temperature }), - ...(input.top_p === undefined ? {} : { top_p: input.top_p }), + ...(temperature === undefined ? {} : { temperature }), + ...(top_p === undefined ? {} : { top_p }), ...(thinking ? { thinking } : {}), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), - ...(tools?.length ? { tools } : {}), + ...(tools.length ? { tools } : {}), }; } -export function fromAnthropicResponse(response: any, model: string): Record { - const content = Array.isArray(response.content) ? response.content : []; - const output: any[] = []; - const thinkingText = content.filter((part: any) => part?.type === "thinking").map((part: any) => part.thinking ?? "").join(""); +export function fromAnthropicResponse(response: JsonRecord, model: string): JsonRecord { + const content = recObjs(response, "content"); + const output: JsonRecord[] = []; + const thinkingText = textOfBlocks(content, "thinking"); if (thinkingText) output.push({ type: "reasoning", id: `rs_${crypto.randomUUID()}`, summary: [{ type: "summary_text", text: thinkingText }] }); - const text = content.filter((part: any) => part?.type === "text").map((part: any) => part.text ?? "").join(""); + const text = textOfBlocks(content, "text"); if (text) output.push({ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text }] }); for (const part of content) { - if (part?.type !== "tool_use") continue; - output.push({ type: "function_call", call_id: part.id, name: part.name, arguments: JSON.stringify(part.input ?? {}), status: "completed" }); + if (part.type !== "tool_use") continue; + output.push({ type: "function_call", call_id: recStr(part, "id"), name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}), status: "completed" }); } const incomplete = response.stop_reason === "max_tokens"; - const inputTokens = response.usage?.input_tokens ?? 0; - const outputTokens = response.usage?.output_tokens ?? 0; - const cached = response.usage?.cache_read_input_tokens; + const usage = recObj(response, "usage"); + const inputTokens = recNum(usage, "input_tokens") ?? 0; + const outputTokens = recNum(usage, "output_tokens") ?? 0; + const cached = recNum(usage, "cache_read_input_tokens"); return { - id: response.id ?? `resp_${crypto.randomUUID()}`, + id: recStr(response, "id") ?? `resp_${crypto.randomUUID()}`, object: "response", status: incomplete ? "incomplete" : "completed", model, @@ -108,7 +115,7 @@ export function fromAnthropicResponse(response: any, model: string): Record { - const source = tool?.function ?? tool; - if (!source || typeof source.name !== "string") return undefined; - return { name: source.name, ...(source.description === undefined ? {} : { description: source.description }), input_schema: source.parameters ?? { type: "object", properties: {} } }; - }).filter((tool) => tool !== undefined); +function chatToolsToAnthropic(tools: unknown): AnthropicRequest["tools"] | undefined { + const mapped = asRecords(tools).flatMap((tool) => { + const source = recObj(tool, "function") ?? tool; + const name = recStr(source, "name"); + if (!name) return []; + const description = recStr(source, "description"); + return [{ name, ...(description === undefined ? {} : { description }), input_schema: source.parameters ?? { type: "object", properties: {} } }]; + }); return mapped.length ? mapped : undefined; } -function chatContentToAnthropic(content: unknown): unknown { +/** Chat Completions content (string or text/image_url parts) to Anthropic content. */ +function chatContentToAnthropic(content: JsonValue): JsonValue { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; - const parts = content.map((part: any) => { - if (part?.type === "text" && typeof part.text === "string") return { type: "text", text: part.text }; - if (part?.type === "image_url" && typeof part.image_url?.url === "string") { const source = toAnthropicImageSource(part.image_url.url); return source ? { type: "image", source } : undefined; } - return undefined; - }).filter((part) => part !== undefined); - if (!parts.length) return ""; - return parts.every((part: any) => part.type === "text") ? parts.map((part: any) => part.text).join("") : parts; + const parts: JsonRecord[] = []; + for (const raw of content) { + if (!isRecord(raw)) continue; + if (raw.type === "text") { const text = recStr(raw, "text"); if (text) parts.push({ type: "text", text }); continue; } + if (raw.type === "image_url") { const source = toAnthropicImageSource(recStr(recObj(raw, "image_url"), "url")); if (source) parts.push({ type: "image", source }); } + } + return collapseAnthropicContent(parts); } -export function toAnthropicRequestFromChat(input: any, model: string): Record { +export function toAnthropicRequestFromChat(input: JsonRecord, model: string): AnthropicRequest { const systemParts: string[] = []; const messages: AnthropicMessage[] = []; - for (const message of input.messages ?? []) { - if (message.role === "system") { if (typeof message.content === "string") systemParts.push(message.content); continue; } - if (message.role === "tool") { + for (const message of recObjs(input, "messages")) { + const role = recStr(message, "role"); + if (role === "system") { const content = recStr(message, "content"); if (content) systemParts.push(content); continue; } + if (role === "tool") { messages.push({ role: "user", content: [{ type: "tool_result", tool_use_id: message.tool_call_id, content: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "") }] }); continue; } - const blocks: any[] = []; + if (typeof role !== "string") continue; + const blocks: JsonRecord[] = []; const converted = chatContentToAnthropic(message.content); - if (Array.isArray(converted)) blocks.push(...converted); + if (Array.isArray(converted)) blocks.push(...asRecords(converted)); else if (converted) blocks.push({ type: "text", text: converted }); - for (const call of message.tool_calls ?? []) blocks.push({ type: "tool_use", id: call.id, name: call.function?.name, input: parse(call.function?.arguments) }); - if (blocks.length) messages.push({ role: message.role, content: blocks }); + for (const call of recObjs(message, "tool_calls")) blocks.push({ type: "tool_use", id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), input: parse(recStr(recObj(call, "function"), "arguments")) }); + if (blocks.length) messages.push({ role, content: blocks }); } - const stopSequences = typeof input.stop === "string" ? [input.stop] : Array.isArray(input.stop) ? input.stop : undefined; - const toolChoice = chatToolChoiceToAnthropic(input.tool_choice); + const rawStop = input.stop; + const stopSequences = typeof rawStop === "string" ? [rawStop] : Array.isArray(rawStop) ? rawStop.filter((part): part is string => typeof part === "string") : undefined; + const toolChoice = anthropicToolChoice(input.tool_choice); const tools = chatToolsToAnthropic(input.tools); + const temperature = recNum(input, "temperature"); + const top_p = recNum(input, "top_p"); return { model, messages, - max_tokens: input.max_tokens ?? 4096, + max_tokens: recNum(input, "max_tokens") ?? 4096, ...(systemParts.length ? { system: systemParts.join("\n") } : {}), ...(input.stream ? { stream: true } : {}), - ...(input.temperature === undefined ? {} : { temperature: input.temperature }), - ...(input.top_p === undefined ? {} : { top_p: input.top_p }), + ...(temperature === undefined ? {} : { temperature }), + ...(top_p === undefined ? {} : { top_p }), ...(stopSequences ? { stop_sequences: stopSequences } : {}), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), ...(tools ? { tools } : {}), }; } -export function fromAnthropicResponseToChat(response: any, model: string): Record { - const content = Array.isArray(response.content) ? response.content : []; - const text = content.filter((part: any) => part?.type === "text").map((part: any) => part.text ?? "").join(""); - const thinking = content.filter((part: any) => part?.type === "thinking").map((part: any) => part.thinking ?? "").join(""); - const toolCalls = content.filter((part: any) => part?.type === "tool_use").map((part: any) => ({ id: part.id, type: "function", function: { name: part.name, arguments: JSON.stringify(part.input ?? {}) } })); - const inputTokens = response.usage?.input_tokens ?? 0; - const outputTokens = response.usage?.output_tokens ?? 0; - const cached = response.usage?.cache_read_input_tokens; +export function fromAnthropicResponseToChat(response: JsonRecord, model: string): JsonRecord { + const content = recObjs(response, "content"); + const text = textOfBlocks(content, "text"); + const thinking = textOfBlocks(content, "thinking"); + const toolCalls = content.filter((part) => part.type === "tool_use").map((part) => ({ id: recStr(part, "id"), type: "function", function: { name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}) } })); + const usage = recObj(response, "usage"); + const inputTokens = recNum(usage, "input_tokens") ?? 0; + const outputTokens = recNum(usage, "output_tokens") ?? 0; + const cached = recNum(usage, "cache_read_input_tokens"); return { - id: response.id ?? `chatcmpl_${crypto.randomUUID()}`, + id: recStr(response, "id") ?? `chatcmpl_${crypto.randomUUID()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, @@ -212,7 +214,7 @@ export function fromAnthropicResponseToChat(response: any, model: string): Recor prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens, - ...(cached !== undefined && cached !== null ? { prompt_tokens_details: { cached_tokens: Number(cached) } } : {}), + ...(cached === undefined ? {} : { prompt_tokens_details: { cached_tokens: cached } }), }, }; } diff --git a/src/convert/chat.ts b/src/convert/chat.ts index 94921b7..b9e2dc7 100644 --- a/src/convert/chat.ts +++ b/src/convert/chat.ts @@ -4,67 +4,72 @@ * Completions protocol. */ import type { AnthropicMessage, AnthropicRequest } from "./shared.js"; -import { acceptsImageInput, chatControlParams, imageDataUri, parse, samplingParams, toolResultContent, toolResultText, TOOL_RESULT_MEDIA_PROMPT } from "./shared.js"; +import type { JsonRecord, JsonValue } from "../json.js"; +import { isRecord, parse, recNum, recObj, recObjs, recStr } from "../json.js"; +import { acceptsImageInput, chatControlParams, collapseAnthropicContent, imageDataUri, samplingParams, toolResultContent, toolResultText, TOOL_RESULT_MEDIA_PROMPT } from "./shared.js"; import type { ProviderModel } from "../providers/types.js"; import { isDeepSeekLongContextModel } from "../providers/registry.js"; +/** One Chat Completions message as sent upstream (or received back). */ +type ChatMessage = JsonRecord; + export function toChatRequest(input: AnthropicRequest, model: string, provider?: ProviderModel) { - const messages: any[] = []; + const messages: ChatMessage[] = []; if (input.system) messages.push({ role: "system", content: typeof input.system === "string" ? input.system : input.system.map((part) => part.text ?? "").join("\n") }); const deepSeek = provider?.provider === "deepseek" || isDeepSeekLongContextModel(model); const allowImages = acceptsImageInput(provider); for (const message of input.messages) messages.push(...toChatMessages(message, deepSeek, allowImages)); - return { model, messages, ...(input.max_tokens === undefined ? {} : { max_tokens: input.max_tokens }), ...(input.stream ? { stream: true } : {}), ...samplingParams(input as any), ...chatControlParams(input, deepSeek), ...(input.tools ? { tools: input.tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.input_schema } })) } : {}) }; + return { model, messages, ...(input.max_tokens === undefined ? {} : { max_tokens: input.max_tokens }), ...(input.stream ? { stream: true } : {}), ...samplingParams(input), ...chatControlParams(input, deepSeek), ...(input.tools ? { tools: input.tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.input_schema } })) } : {}) }; } -function toChatImagePart(part: any): any | undefined { - const url = imageDataUri(part?.source); +function toChatImagePart(part: JsonRecord | undefined): ChatMessage | undefined { + const url = imageDataUri(recObj(part, "source")); return url ? { type: "image_url", image_url: { url } } : undefined; } -function toChatMessages(message: AnthropicMessage, preserveReasoning = false, allowImages = true): any[] { +function toChatMessages(message: AnthropicMessage, preserveReasoning = false, allowImages = true): ChatMessage[] { if (!Array.isArray(message.content)) return [{ role: message.role, content: message.content ?? "" }]; - const parts: any[] = []; - const toolCalls: any[] = []; - const toolResults: any[] = []; + const parts: ChatMessage[] = []; + const toolCalls: ChatMessage[] = []; + const toolResults: ChatMessage[] = []; /** Images lifted out of tool results; they follow as their own user message. */ const toolMedia: string[] = []; let reasoning = ""; const pushText = (text: string) => { if (!text) return; const last = parts[parts.length - 1]; - if (last?.type === "text") last.text += text; + if (last?.type === "text") last.text = `${recStr(last, "text") ?? ""}${text}`; else parts.push({ type: "text", text }); }; - for (const part of message.content) { + for (const raw of message.content) { + if (!isRecord(raw)) continue; + const part = raw; if (part.type === "tool_use") { - toolCalls.push({ id: part.id, type: "function", function: { name: part.name, arguments: JSON.stringify(part.input ?? {}) } }); + toolCalls.push({ id: recStr(part, "id"), type: "function", function: { name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}) } }); } else if (part.type === "tool_result") { const { text, images } = toolResultContent(part.content); if (allowImages) toolMedia.push(...images); toolResults.push({ role: "tool", tool_call_id: part.tool_use_id, content: toolResultText(text, images.length, allowImages) }); } else if (part.type === "text") { - pushText(part.text ?? ""); + pushText(recStr(part, "text") ?? ""); } else if (part.type === "thinking" && preserveReasoning && message.role === "assistant") { - if (typeof part.thinking === "string") reasoning += part.thinking; + const thinking = recStr(part, "thinking"); + if (thinking) reasoning += thinking; } else if (part.type === "image") { const image = toChatImagePart(part); if (image) parts.push(image); } } - const output: any[] = []; + const output: ChatMessage[] = []; if (message.role === "assistant" && (parts.length || toolCalls.length || reasoning)) { - const assistant: any = { + output.push({ role: "assistant", - content: parts.length - ? parts.every((part) => part.type === "text") ? parts.map((part) => part.text).join("") : parts - : null, + content: parts.length ? collapseAnthropicContent(parts) : null, ...(reasoning ? { reasoning_content: reasoning } : {}), ...(toolCalls.length ? { tool_calls: toolCalls } : {}), - }; - output.push(assistant); + }); } else if (parts.length) { - output.push({ role: message.role, content: parts.every((part) => part.type === "text") ? parts.map((part) => part.text).join("") : parts }); + output.push({ role: message.role, content: collapseAnthropicContent(parts) }); } output.push(...toolResults); // Chat Completions has no place for an image inside a tool message, so the @@ -75,12 +80,17 @@ function toChatMessages(message: AnthropicMessage, preserveReasoning = false, al return output; } /** Plain text of a chat message content, ignoring non-text parts (images etc.). */ -function chatText(content: any): string { +function chatText(content: unknown): string { if (typeof content === "string") return content; - if (Array.isArray(content)) return content.filter((part) => part?.type === "text").map((part) => part.text ?? "").join(""); + if (Array.isArray(content)) return content.filter(isRecord).filter((part) => part.type === "text").map((part) => recStr(part, "text") ?? "").join(""); return ""; } +/** Nested usage details of a Chat Completions response (`prompt_tokens_details`, …). */ +function chatUsageDetails(response: JsonRecord, usageKey: string, detailKey: string, field: string): number | undefined { + return recNum(recObj(recObj(response, usageKey), detailKey), field); +} + /** * Map a Chat Completions `finish_reason` to an Anthropic `stop_reason`. * Unrecognized reasons used to fall through to "max_tokens", telling the @@ -88,25 +98,29 @@ function chatText(content: any): string { * default. `chatResponseFailure` still turns the genuinely abnormal reasons * into an upstream error before this is reached on the proxy path. */ -function anthropicStopReason(finishReason: unknown, hasToolCalls: boolean): string { +function anthropicStopReason(finishReason: JsonValue, hasToolCalls: boolean): string { if (hasToolCalls) return "tool_use"; if (finishReason === "length") return "max_tokens"; if (finishReason === "content_filter") return "refusal"; return "end_turn"; } -export function fromChatResponse(response: any, model: string): Record { - const choice = response.choices?.[0] ?? {}; const message = choice.message ?? {}; const content = []; - if (message.reasoning_content) content.push({ type: "thinking", thinking: message.reasoning_content }); +export function fromChatResponse(response: JsonRecord, model: string): JsonRecord { + const choice = recObjs(response, "choices")[0] ?? {}; const message = recObj(choice, "message") ?? {}; const content: JsonRecord[] = []; + const reasoningContent = recStr(message, "reasoning_content"); + if (reasoningContent) content.push({ type: "thinking", thinking: reasoningContent }); const text = chatText(message.content); if (text) content.push({ type: "text", text }); - for (const call of message.tool_calls ?? []) content.push({ type: "tool_use", id: call.id, name: call.function?.name, input: parse(call.function?.arguments) }); - return { id: response.id ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, content, stop_reason: anthropicStopReason(choice.finish_reason, Boolean(message.tool_calls?.length)), stop_sequence: null, usage: { input_tokens: response.usage?.prompt_tokens ?? 0, output_tokens: response.usage?.completion_tokens ?? 0, cache_creation_input_tokens: 0, cache_read_input_tokens: response.usage?.prompt_tokens_details?.cached_tokens ?? 0 } }; + const toolCalls = recObjs(message, "tool_calls"); + for (const call of toolCalls) content.push({ type: "tool_use", id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), input: parse(recStr(recObj(call, "function"), "arguments")) }); + const usage = recObj(response, "usage"); + return { id: recStr(response, "id") ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, content, stop_reason: anthropicStopReason(choice.finish_reason, toolCalls.length > 0), stop_sequence: null, usage: { input_tokens: recNum(usage, "prompt_tokens") ?? 0, output_tokens: recNum(usage, "completion_tokens") ?? 0, cache_creation_input_tokens: 0, cache_read_input_tokens: chatUsageDetails(response, "usage", "prompt_tokens_details", "cached_tokens") ?? 0 } }; } /** Return a failure for a provider completion that is not a normal stop. */ -export function chatResponseFailure(response: any): string | undefined { - const reason = response?.choices?.[0]?.finish_reason; +export function chatResponseFailure(response: unknown): string | undefined { + if (!isRecord(response)) return undefined; + const reason = recObjs(response, "choices")[0]?.finish_reason; if (reason === undefined || reason === null || reason === "stop" || reason === "length" || reason === "tool_calls" || reason === "function_call") return undefined; if (reason === "insufficient_system_resource") return "The upstream model stopped because inference resources were insufficient."; if (reason === "content_filter") return "The upstream model stopped because the response was filtered."; @@ -121,22 +135,26 @@ export function chatResponseFailure(response: any): string | undefined { * dropped rather than forwarded as nameless function entries, which strict * upstreams reject as invalid parameters. */ -function toChatTools(tools: unknown): any[] { - return Array.isArray(tools) ? tools.flatMap((tool: any) => { - if (Array.isArray(tool?.tools)) return toChatTools(tool.tools); +function toChatTools(tools: unknown): JsonRecord[] { + return Array.isArray(tools) ? tools.flatMap((raw: unknown) => { + if (!isRecord(raw)) return []; + if (Array.isArray(raw.tools)) return toChatTools(raw.tools); // Server-side built-ins (typed, no nested function) have no chat // representation; everything else must resolve to a named function. - if (tool?.type !== undefined && tool.type !== "function" && !tool.function) return []; - const source = tool?.function ?? tool; - if (!source || typeof source.name !== "string" || !source.name) return []; - return [{ type: "function", function: { name: source.name, ...(source.description === undefined ? {} : { description: source.description }), ...(source.parameters === undefined ? {} : { parameters: source.parameters }) } }]; + if (raw.type !== undefined && raw.type !== "function" && !raw.function) return []; + const source = recObj(raw, "function") ?? raw; + const name = recStr(source, "name"); + if (!name) return []; + return [{ type: "function", function: { name, ...(source.description === undefined ? {} : { description: source.description }), ...(source.parameters === undefined ? {} : { parameters: source.parameters }) } }]; }) : []; } -export function toChatCompletionsRequest(input: any, model: string, provider?: string) { - const messages: any[] = []; - if (input.instructions) messages.push({ role: "system", content: input.instructions }); - const items = typeof input.input === "string" ? [{ role: "user", content: input.input }] : input.input ?? []; +export function toChatCompletionsRequest(input: JsonRecord, model: string, provider?: string) { + const messages: ChatMessage[] = []; + const instructions = recStr(input, "instructions"); + if (instructions) messages.push({ role: "system", content: instructions }); + const rawInput = input.input; + const items: JsonRecord[] = typeof rawInput === "string" ? [{ role: "user", content: rawInput }] : Array.isArray(rawInput) ? rawInput.filter(isRecord) : []; // reasoning_content is a DeepSeek-specific extension; forwarding it to other // chat-completions upstreams risks a strict schema rejecting the request. const deepSeek = provider === "deepseek" || isDeepSeekLongContextModel(model); @@ -145,12 +163,12 @@ export function toChatCompletionsRequest(input: any, model: string, provider?: s if (item.type === "reasoning") { if (deepSeek) pendingReasoning += responseReasoningText(item); } else if (item.type === "function_call") { - const call = { id: item.call_id ?? item.id, type: "function", function: { name: item.name, arguments: item.arguments ?? "{}" } }; + const call = { id: recStr(item, "call_id") ?? recStr(item, "id"), type: "function", function: { name: recStr(item, "name"), arguments: recStr(item, "arguments") ?? "{}" } }; messages.push({ role: "assistant", content: null, ...(pendingReasoning ? { reasoning_content: pendingReasoning } : {}), tool_calls: [call] }); pendingReasoning = ""; } else if (item.type === "function_call_output") { messages.push({ role: "tool", tool_call_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? "") }); - } else if (item.role) { + } else if (typeof item.role === "string") { const role = item.role === "developer" ? "system" : item.role; messages.push({ role, content: responseContent(item.content), ...(role === "assistant" && pendingReasoning ? { reasoning_content: pendingReasoning } : {}) }); pendingReasoning = ""; @@ -161,34 +179,39 @@ export function toChatCompletionsRequest(input: any, model: string, provider?: s return { model, messages, ...(input.max_output_tokens === undefined ? {} : { max_tokens: input.max_output_tokens }), ...(input.stream ? { stream: true } : {}), ...samplingParams(input), ...chatControlParams(input, deepSeek), ...(tools.length ? { tools } : {}) }; } -function responseReasoningText(item: any): string { - return (item.summary ?? []) - .filter((part: any) => part?.type === "summary_text" && typeof part.text === "string") - .map((part: any) => part.text) +function responseReasoningText(item: JsonRecord): string { + return recObjs(item, "summary") + .filter((part) => part.type === "summary_text" && typeof part.text === "string") + .map((part) => recStr(part, "text") ?? "") .join(""); } -export function fromChatResponseToResponses(response: any, model: string): Record { - const choice = response.choices?.[0] ?? {}; const message = choice.message ?? {}; const output: any[] = []; - if (message.reasoning_content) output.push({ type: "reasoning", id: `rs_${crypto.randomUUID()}`, summary: [{ type: "summary_text", text: message.reasoning_content }] }); +export function fromChatResponseToResponses(response: JsonRecord, model: string): JsonRecord { + const choice = recObjs(response, "choices")[0] ?? {}; const message = recObj(choice, "message") ?? {}; const output: JsonRecord[] = []; + const reasoningContent = recStr(message, "reasoning_content"); + if (reasoningContent) output.push({ type: "reasoning", id: `rs_${crypto.randomUUID()}`, summary: [{ type: "summary_text", text: reasoningContent }] }); const text = chatText(message.content); if (text) output.push({ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text }] }); - for (const call of message.tool_calls ?? []) output.push({ type: "function_call", call_id: call.id, name: call.function?.name, arguments: call.function?.arguments ?? "{}", status: "completed" }); - const cached = response.usage?.prompt_tokens_details?.cached_tokens; - const reasoning = response.usage?.completion_tokens_details?.reasoning_tokens; + for (const call of recObjs(message, "tool_calls")) output.push({ type: "function_call", call_id: recStr(call, "id"), name: recStr(recObj(call, "function"), "name"), arguments: recStr(recObj(call, "function"), "arguments") ?? "{}", status: "completed" }); + const usage = recObj(response, "usage"); + const cached = chatUsageDetails(response, "usage", "prompt_tokens_details", "cached_tokens"); + const reasoningTokens = chatUsageDetails(response, "usage", "completion_tokens_details", "reasoning_tokens"); const incomplete = choice.finish_reason === "length"; - return { id: response.id ?? `resp_${crypto.randomUUID()}`, object: "response", status: incomplete ? "incomplete" : "completed", model, output, usage: { input_tokens: response.usage?.prompt_tokens ?? 0, output_tokens: response.usage?.completion_tokens ?? 0, total_tokens: response.usage?.total_tokens ?? 0, ...(cached !== undefined && cached !== null ? { input_tokens_details: { cached_tokens: Number(cached) } } : {}), ...(reasoning !== undefined && reasoning !== null ? { output_tokens_details: { reasoning_tokens: Number(reasoning) } } : {}) }, ...(incomplete ? { incomplete_details: { reason: "max_output_tokens" } } : {}) }; + return { id: recStr(response, "id") ?? `resp_${crypto.randomUUID()}`, object: "response", status: incomplete ? "incomplete" : "completed", model, output, usage: { input_tokens: recNum(usage, "prompt_tokens") ?? 0, output_tokens: recNum(usage, "completion_tokens") ?? 0, total_tokens: recNum(usage, "total_tokens") ?? 0, ...(cached !== undefined ? { input_tokens_details: { cached_tokens: Number(cached) } } : {}), ...(reasoningTokens !== undefined ? { output_tokens_details: { reasoning_tokens: Number(reasoningTokens) } } : {}) }, ...(incomplete ? { incomplete_details: { reason: "max_output_tokens" } } : {}) }; } -function responseContent(content: any): any { +function responseContent(content: JsonValue): JsonValue { if (!Array.isArray(content)) return content ?? ""; - const parts = content.map((part) => { - if (part == null || part === "") return undefined; - if (part.type === "input_text" || part.type === "output_text") return part.text ?? "" ? { type: "text", text: part.text ?? "" } : undefined; - if (part.type === "input_image" && part.image_url) return { type: "image_url", image_url: { url: part.image_url } }; - return typeof part === "string" ? { type: "text", text: part } : part; - }).filter((part) => part !== undefined); - if (!parts.length) return ""; - // Plain text stays a string; anything structured keeps the content array. - return parts.every((part) => part.type === "text") ? parts.map((part) => part.text).join("") : parts; + const parts: JsonRecord[] = []; + for (const raw of content) { + // Bare strings are the common shorthand for a text part. + if (typeof raw === "string") { if (raw) parts.push({ type: "text", text: raw }); continue; } + if (!isRecord(raw)) continue; + const text = recStr(raw, "text") ?? ""; + if (raw.type === "input_text" || raw.type === "output_text") { if (text) parts.push({ type: "text", text }); continue; } + const url = recStr(raw, "image_url"); + if (raw.type === "input_image" && url) { parts.push({ type: "image_url", image_url: { url } }); continue; } + parts.push(raw); + } + return collapseAnthropicContent(parts); } diff --git a/src/convert/responses.ts b/src/convert/responses.ts index 2cf341c..5ab37fc 100644 --- a/src/convert/responses.ts +++ b/src/convert/responses.ts @@ -1,18 +1,21 @@ /** Anthropic Messages API <-> Responses API: the direction whose upstream speaks the Responses protocol. */ -import type { AnthropicMessage, AnthropicRequest } from "./shared.js"; -import { acceptsImageInput, chatThinking, imageDataUri, parse, reasoningEffort, responsesToolChoice, toolResultContent, toolResultText } from "./shared.js"; +import type { AnthropicMessage, AnthropicRequest, ResponsesItem } from "./shared.js"; +import type { JsonRecord, JsonValue } from "../json.js"; +import { asRecords, isRecord, parse, recNum, recObj, recObjs, recStr } from "../json.js"; +import { acceptsImageInput, chatThinking, imageDataUri, reasoningEffort, responsesToolChoice, toolResultContent, toolResultText } from "./shared.js"; import type { ProviderModel } from "../providers/types.js"; import { fromAnthropicResponseToChat, toAnthropicRequestFromChat } from "./anthropic.js"; -function convertContent(content: any, role: string): any { - if (!Array.isArray(content)) return content; - return content.map((part) => { - if (part.type === "text") return { type: role === "assistant" ? "output_text" : "input_text", text: part.text ?? "" }; - if (part.type === "image") { const url = imageDataUri(part.source); return url ? { type: "input_image", image_url: url } : null; } - // tool_use / tool_result never reach here: toResponsesInput handles those +function convertContent(content: JsonValue[], assistant: boolean): JsonValue[] { + return content.flatMap((raw) => { + if (!isRecord(raw)) return []; + const part = raw; + if (part.type === "text") return [{ type: assistant ? "output_text" : "input_text", text: recStr(part, "text") ?? "" }]; + if (part.type === "image") { const url = imageDataUri(recObj(part, "source")); return url ? [{ type: "input_image", image_url: url }] : []; } + // tool_use / tool_result never reach here: toResponsesInput maps those // blocks itself and only sends single non-tool parts through this map. - return part; - }).filter((part) => part !== null); + return [part]; + }); } /** @@ -22,7 +25,7 @@ function convertContent(content: any, role: string): any { * a real `input_image` instead of being flattened into a JSON string that * carried its base64 upstream as literal text. */ -function toFunctionCallOutput(part: any, allowImages: boolean): Record { +function toFunctionCallOutput(part: JsonRecord, allowImages: boolean): ResponsesItem { const { text, images } = toolResultContent(part.content); const media = allowImages ? images : []; if (!media.length) return { type: "function_call_output", call_id: part.tool_use_id, output: toolResultText(text, images.length, false) }; @@ -36,11 +39,11 @@ function toFunctionCallOutput(part: any, allowImages: boolean): Record { +export function toResponsesRequest(input: AnthropicRequest, model: string, provider?: ProviderModel): JsonRecord { const thinking = chatThinking(input); const effort = reasoningEffort(input); const toolChoice = responsesToolChoice(input.tool_choice); - const body: Record = { + const body: JsonRecord = { model, input: toResponsesInput(input.messages, acceptsImageInput(provider)), ...(input.max_tokens === undefined ? {} : { max_output_tokens: input.max_tokens }), ...(input.stream ? { stream: true } : {}), @@ -59,64 +62,73 @@ export function toResponsesRequest(input: AnthropicRequest, model: string, provi } /** Thinking blocks are local-only reasoning echoes from Claude Code; upstream providers never accept them. */ -function isThinkingPart(part: any): boolean { return part?.type === "thinking" || part?.type === "redacted_thinking"; } +function isThinkingPart(part: JsonRecord): boolean { return part.type === "thinking" || part.type === "redacted_thinking"; } -function toResponsesInput(messages: AnthropicMessage[], allowImages = true): any[] { - const output: any[] = []; +function toResponsesInput(messages: AnthropicMessage[], allowImages = true): ResponsesItem[] { + const output: ResponsesItem[] = []; for (const message of messages) { - if (!Array.isArray(message.content)) { output.push({ ...message, content: convertContent(message.content, message.role) }); continue; } - const textParts: any[] = []; - for (const part of message.content as any[]) { + const assistant = message.role === "assistant"; + if (!Array.isArray(message.content)) { output.push({ ...message }); continue; } + const textParts: ResponsesItem[] = []; + for (const raw of message.content) { + if (!isRecord(raw)) continue; + const part = raw; if (isThinkingPart(part)) continue; - if (part.type === "tool_use") { output.push({ type: "function_call", call_id: part.id, name: part.name, arguments: JSON.stringify(part.input ?? {}) }); continue; } + if (part.type === "tool_use") { output.push({ type: "function_call", call_id: recStr(part, "id"), name: recStr(part, "name"), arguments: JSON.stringify(part.input ?? {}) }); continue; } if (part.type === "tool_result") { output.push(toFunctionCallOutput(part, allowImages)); continue; } - const converted = convertContent([part], message.role)[0]; if (converted) textParts.push(converted); + const converted = convertContent([part], assistant)[0]; + if (isRecord(converted)) textParts.push(converted); } if (textParts.length) output.push({ role: message.role, content: textParts }); } return output; } -function reasoningText(output: any[]): string { - return (output ?? []) - .filter((item: any) => item.type === "reasoning") - .flatMap((item: any) => [ - ...(item.summary ?? []).map((part: any) => part.text ?? ""), - ...(item.content ?? []).filter((part: any) => part.type === "reasoning_text").map((part: any) => part.text ?? "") +function reasoningText(output: ResponsesItem[]): string { + return output + .filter((item) => item.type === "reasoning") + .flatMap((item) => [ + ...recObjs(item, "summary").map((part) => recStr(part, "text") ?? ""), + ...recObjs(item, "content").filter((part) => part.type === "reasoning_text").map((part) => recStr(part, "text") ?? ""), ]) .join(""); } -export function fromResponsesResponse(response: any, model: string): Record { - const output = response.output ?? []; +export function fromResponsesResponse(response: JsonRecord, model: string): JsonRecord { + const output = asRecords(response.output); const text = output - .filter((item: any) => item.type === "message") - .flatMap((item: any) => item.content ?? []) - .filter((part: any) => part.type === "output_text") - .map((part: any) => part.text ?? "").join(""); - const toolUses = output.filter((item: any) => item.type === "function_call").map((item: any) => ({ type: "tool_use", id: item.call_id ?? item.id, name: item.name, input: parse(item.arguments) })); + .filter((item) => item.type === "message") + .flatMap((item) => asRecords(item.content)) + .filter((part) => part.type === "output_text") + .map((part) => recStr(part, "text") ?? "").join(""); + const toolUses = output.filter((item) => item.type === "function_call").map((item) => ({ type: "tool_use", id: recStr(item, "call_id") ?? recStr(item, "id"), name: recStr(item, "name"), input: parse(recStr(item, "arguments")) })); const thinking = reasoningText(output); + const usage = recObj(response, "usage"); + const cached = recNum(recObj(usage, "input_tokens_details"), "cached_tokens"); return { - id: response.id ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, + id: recStr(response, "id") ?? `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", model, content: [...(thinking ? [{ type: "thinking", thinking }] : []), ...(text ? [{ type: "text", text }] : []), ...toolUses], stop_reason: toolUses.length ? "tool_use" : response.status === "incomplete" ? "max_tokens" : "end_turn", stop_sequence: null, usage: { - input_tokens: response.usage?.input_tokens ?? 0, - output_tokens: response.usage?.output_tokens ?? 0, + input_tokens: recNum(usage, "input_tokens") ?? 0, + output_tokens: recNum(usage, "output_tokens") ?? 0, cache_creation_input_tokens: 0, - cache_read_input_tokens: response.usage?.input_tokens_details?.cached_tokens ?? 0 + cache_read_input_tokens: cached ?? 0 } }; } /** Return a failure for a Responses result that must not look like a turn end. */ -export function responsesResponseFailure(response: any): string | undefined { - if (response?.status === "failed") { - return response.error?.message ?? response.error ?? "The upstream response failed."; +export function responsesResponseFailure(response: unknown): string | undefined { + if (!isRecord(response)) return undefined; + if (response.status === "failed") { + const error = response.error; + return (isRecord(error) ? recStr(error, "message") : undefined) ?? (typeof error === "string" ? error : undefined) ?? "The upstream response failed."; } - if (response?.status === "incomplete" && response.incomplete_details?.reason !== "max_output_tokens") { - return `The upstream response is incomplete (${String(response.incomplete_details?.reason ?? "unknown")}).`; + if (response.status === "incomplete") { + const reason = recStr(recObj(response, "incomplete_details"), "reason"); + if (reason !== "max_output_tokens") return `The upstream response is incomplete (${String(reason ?? "unknown")}).`; } return undefined; } @@ -127,11 +139,11 @@ export function responsesResponseFailure(response: any): string | undefined { * `toAnthropicRequestFromChat`/`toResponsesRequest` instead of writing a * separate chat->Responses request mapping. */ -export function toResponsesRequestFromChat(input: any, model: string): Record { - return toResponsesRequest(toAnthropicRequestFromChat(input, model) as unknown as AnthropicRequest, model); +export function toResponsesRequestFromChat(input: JsonRecord, model: string): JsonRecord { + return toResponsesRequest(toAnthropicRequestFromChat(input, model), model); } /** Reuses `fromResponsesResponse`/`fromAnthropicResponseToChat` instead of writing a separate Responses->chat response mapping. */ -export function fromResponsesResponseToChat(response: any, model: string): Record { +export function fromResponsesResponseToChat(response: JsonRecord, model: string): JsonRecord { return fromAnthropicResponseToChat(fromResponsesResponse(response, model), model); } diff --git a/src/convert/shared.ts b/src/convert/shared.ts index 6e65faa..f2a9a2a 100644 --- a/src/convert/shared.ts +++ b/src/convert/shared.ts @@ -1,35 +1,82 @@ /** * Helpers shared across every conversion direction (chat.ts / responses.ts / - * anthropic.ts): the Anthropic request shape they all consume, image/effort/ - * thinking/tool-choice field mapping, and small parsing utilities. + * anthropic.ts): the Anthropic request shape they all consume and the + * image/effort/thinking/tool-choice field mapping between dialects. The + * dynamic-JSON accessors they read payloads with live in ../json.ts. */ +import type { JsonRecord, JsonValue } from "../json.js"; +import { asRecords, isRecord, recObj, recStr } from "../json.js"; -export interface AnthropicMessage { role: string; content: unknown; } -export interface AnthropicThinking { type?: string; budget_tokens?: number; [key: string]: unknown; } -export interface AnthropicRequest { - model?: string; system?: string | Array<{ type: string; text?: string }>; +/** + * The Anthropic Messages request shape the converters consume: a message + * list plus the few fields whose structure they rely on. `AnthropicMessage` + * is a plain record because a client's messages carry whatever blocks they + * carry; each is read through the accessors from ../json.ts. + */ +export type AnthropicMessage = JsonRecord; +export interface AnthropicThinking extends JsonRecord { type?: string; budget_tokens?: number; } +export interface AnthropicRequest extends JsonRecord { + system?: string | Array<{ type: string; text?: string }>; max_tokens?: number; messages: AnthropicMessage[]; stream?: boolean; temperature?: number; top_p?: number; stop_sequences?: string[]; - tools?: Array<{ name: string; description?: string; input_schema: unknown }>; - thinking?: AnthropicThinking; - output_config?: { effort?: string; [key: string]: unknown }; - tool_choice?: unknown; - metadata?: unknown; - context_management?: unknown; + tools?: Array<{ name: string; description?: string; input_schema: JsonValue }>; +} + +/** + * Interpret a payload from the Anthropic-shaped `/v1/messages` endpoint as an + * `AnthropicRequest`. The endpoint's contract *is* that shape, so this is the + * one place it is asserted rather than re-checked; `messages` is still + * filtered to its object entries so converters can iterate it safely, and a + * body that violates the rest fails at the upstream. + */ +export function asAnthropicRequest(value: JsonRecord): AnthropicRequest { + return { ...value, messages: asRecords(value.messages) } as AnthropicRequest; +} + +/** + * One top-level entry of a Responses request's `input` (message, function_call, + * reasoning, function_call_output…). Raw client JSON, so fields are read + * through the accessors above rather than declared here. + */ +export type ResponsesItem = JsonRecord; + +/** Build an Anthropic image content block from a Responses `input_image.image_url`, which may be a real URL or a data: URI. */ +export function toAnthropicImageSource(url: string | undefined): { type: "url"; url: string } | { type: "base64"; media_type: string; data: string } | undefined { + if (!url) return undefined; + const dataMatch = /^data:([^;]+);base64,(.+)$/.exec(url); + if (dataMatch) { + const [, media_type = "", data = ""] = dataMatch; + return { type: "base64", media_type, data }; + } + return { type: "url", url }; +} + +/** Shared collapse rule for content arrays: pure-text arrays flatten to a joined string; mixed arrays stay blocks; empty becomes "". */ +export function collapseAnthropicContent(parts: JsonRecord[]): JsonValue { + if (!parts.length) return ""; + return parts.every((part) => part.type === "text") ? parts.map((part) => recStr(part, "text") ?? "").join("") : parts; +} + +/** Plain text of an Anthropic content array for the given block type, ignoring everything else. */ +export function textOfBlocks(content: JsonRecord[], type: "text" | "thinking"): string { + const key = type === "text" ? "text" : "thinking"; + return content.filter((part) => part.type === type).map((part) => recStr(part, key) ?? "").join(""); } /** Build a data URI (or pass through remote URLs) from an Anthropic image source. */ -export function imageDataUri(source: any): string | undefined { +export function imageDataUri(source: JsonRecord | undefined): string | undefined { if (!source) return undefined; - if (source.type === "url" && typeof source.url === "string") return source.url; - if (source.type === "base64" && typeof source.data === "string") return `data:${source.media_type ?? "image/png"};base64,${source.data}`; + if (source.type === "url") return recStr(source, "url"); + if (source.type === "base64") { + const data = recStr(source, "data"); + return data ? `data:${recStr(source, "media_type") ?? "image/png"};base64,${data}` : undefined; + } return undefined; } /** Normalize Claude/DeepSeek/Codex effort names to the Chat Completions values. */ -export function reasoningEffort(input: any): "low" | "high" | "max" | undefined { - const value = input?.output_config?.effort ?? input?.reasoning?.effort; - if (typeof value !== "string") return undefined; +export function reasoningEffort(input: JsonRecord | undefined): "low" | "high" | "max" | undefined { + const value = recStr(recObj(input, "output_config"), "effort") ?? recStr(recObj(input, "reasoning"), "effort"); if (value === "minimal" || value === "low") return "low"; if (value === "medium" || value === "high" || value === "xhigh" || value === "ultracode") return "high"; if (value === "max" || value === "ultra") return "max"; @@ -37,60 +84,68 @@ export function reasoningEffort(input: any): "low" | "high" | "max" | undefined } /** Convert Anthropic thinking controls to DeepSeek's OpenAI-shaped control. */ -export function chatThinking(input: any): { type: "enabled" | "disabled" } | undefined { - const type = input?.thinking?.type; +export function chatThinking(input: JsonRecord | undefined): { type: "enabled" | "disabled" } | undefined { + const type = recStr(recObj(input, "thinking"), "type"); if (type === "disabled") return { type: "disabled" }; if (type === "enabled" || type === "adaptive") return { type: "enabled" }; - if (input?.reasoning?.effort === "none") return { type: "disabled" }; - if (input?.reasoning?.effort !== undefined) return { type: "enabled" }; + const effort = recStr(recObj(input, "reasoning"), "effort"); + if (effort === "none") return { type: "disabled" }; + if (effort !== undefined) return { type: "enabled" }; return undefined; } /** Convert Anthropic tool-choice variants to Chat Completions variants. */ -export function chatToolChoice(value: unknown): unknown { +export function chatToolChoice(value: JsonValue): JsonValue | undefined { if (typeof value === "string") { if (value === "none" || value === "auto") return value; if (value === "any" || value === "required") return "required"; return undefined; } - if (!value || typeof value !== "object") return undefined; - const choice = value as { type?: string; name?: string; function?: { name?: string } }; - if (choice.type === "none" || choice.type === "auto") return choice.type; - if (choice.type === "any" || choice.type === "required") return "required"; - if (choice.type === "tool" && typeof choice.name === "string") { - return { type: "function", function: { name: choice.name } }; - } - if (choice.type === "function" && typeof choice.function?.name === "string") return value; - return undefined; + if (!isRecord(value)) return undefined; + const type = recStr(value, "type"); + if (type === "none" || type === "auto") return type; + if (type === "any" || type === "required") return "required"; + const name = forcedToolName(value); + return name ? { type: "function", function: { name } } : undefined; } /** Convert Anthropic tool-choice variants to Responses API variants. */ -export function responsesToolChoice(value: unknown): unknown { +export function responsesToolChoice(value: JsonValue): JsonValue | undefined { if (typeof value === "string") { if (value === "none" || value === "auto" || value === "required") return value; if (value === "any") return "required"; return undefined; } - if (!value || typeof value !== "object") return undefined; - const choice = value as { type?: string; name?: string; function?: { name?: string } }; - if (choice.type === "none" || choice.type === "auto" || choice.type === "required") return choice.type; - if (choice.type === "any") return "required"; - if (choice.type === "tool" && typeof choice.name === "string") return { type: "function", name: choice.name }; - if (choice.type === "function" && typeof choice.function?.name === "string") return { type: "function", name: choice.function.name }; - return undefined; + if (!isRecord(value)) return undefined; + const type = recStr(value, "type"); + if (type === "none" || type === "auto" || type === "required") return type; + if (type === "any") return "required"; + const name = forcedToolName(value); + return name ? { type: "function", name } : undefined; } -/** Convert Responses tool-choice variants to Anthropic's object-shaped tool_choice ({"type": "auto"|"any"|"none"|"tool", name?}). */ -export function anthropicToolChoice(value: unknown): unknown { +/** Name of the tool a `{"type": "tool"|"function"}` choice forces, in either the Anthropic (top-level `name`) or Chat Completions (`function.name`) dialect. */ +function forcedToolName(choice: JsonRecord): string | undefined { + const type = recStr(choice, "type"); + if (type !== "tool" && type !== "function") return undefined; + return recStr(choice, "name") ?? recStr(recObj(choice, "function"), "name"); +} + +/** + * Convert a Responses or Chat Completions tool-choice variant to Anthropic's + * object-shaped tool_choice ({"type": "auto"|"any"|"none"|"tool", name?}). The + * two dialects name the forced tool differently — Responses uses a top-level + * `name`, Chat Completions nests it under `function` — and both are accepted. + */ +export function anthropicToolChoice(value: unknown): JsonRecord | undefined { if (typeof value === "string") { if (value === "none" || value === "auto") return { type: value }; if (value === "required") return { type: "any" }; return undefined; } - if (!value || typeof value !== "object") return undefined; - const choice = value as { type?: string; name?: string }; - if (choice.type === "function" && typeof choice.name === "string") return { type: "tool", name: choice.name }; - return undefined; + if (!isRecord(value) || value.type !== "function") return undefined; + const name = recStr(value, "name") ?? recStr(recObj(value, "function"), "name"); + return name ? { type: "tool", name } : undefined; } /** @@ -112,7 +167,7 @@ export function anthropicThinking(effort: unknown): AnthropicThinking | undefine } /** Sampling knobs shared by both upstream protocols (undefined drops the key). */ -export function samplingParams(input: Record): Record { +export function samplingParams(input: JsonRecord): JsonRecord { return { ...(input.temperature === undefined ? {} : { temperature: input.temperature }), ...(input.top_p === undefined ? {} : { top_p: input.top_p }), @@ -120,7 +175,7 @@ export function samplingParams(input: Record): Record { +export function chatControlParams(input: JsonRecord, deepSeek: boolean): JsonRecord { const thinking = deepSeek ? chatThinking(input) : undefined; const effort = deepSeek ? reasoningEffort(input) : undefined; const toolChoice = chatToolChoice(input.tool_choice); @@ -131,11 +186,6 @@ export function chatControlParams(input: any, deepSeek: boolean): Record { + for (let attempt = 0; ; attempt += 1) { + try { + return await rename(temporary, file); + } catch (error) { + // Unlike POSIX, Windows rename cannot displace an open destination: an + // antivirus scan or a sibling writer holding the file yields a transient + // permission error rather than a real failure, so back off and retry. + if (attempt === 4 || !LOCKED.has((error as NodeJS.ErrnoException).code ?? "")) throw error; + await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 20)); + } + } } diff --git a/src/json.ts b/src/json.ts new file mode 100644 index 0000000..ce9877e --- /dev/null +++ b/src/json.ts @@ -0,0 +1,61 @@ +/** + * Type vocabulary and field accessors for wire payloads — client requests, + * provider responses, SSE events — that arrive as parsed JSON and cannot be + * fully trusted or fully typed. + * + * `JsonRecord` plus the accessors below replace `any`: every read is still a + * runtime check, but a wrong field name or a missed non-object check now fails + * the type checker instead of producing a silent undefined-at-runtime bug. + * `undefined` is a member of `JsonValue` because optional JSON reads yield it. + */ +export type JsonValue = string | number | boolean | null | undefined | JsonValue[] | { [key: string]: JsonValue }; +export type JsonRecord = { [key: string]: JsonValue }; + +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Object members of a JSON array, non-object entries dropped. */ +export function asRecords(value: unknown): JsonRecord[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +/** Narrow a parsed-JSON payload to a record; anything else reads as an empty object. */ +export function jsonRecord(value: unknown): JsonRecord { + return isRecord(value) ? value : {}; +} + +/** Named field reads, each undefined unless the payload carries the right type. */ +export function recStr(obj: JsonRecord | undefined, key: string): string | undefined { + const value = obj?.[key]; + return typeof value === "string" ? value : undefined; +} +export function recNum(obj: JsonRecord | undefined, key: string): number | undefined { + const value = obj?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} +/** Nested object field, undefined for arrays and non-objects. */ +export function recObj(obj: JsonRecord | undefined, key: string): JsonRecord | undefined { + const value = obj?.[key]; + return isRecord(value) ? value : undefined; +} +/** Nested array field as object members (see `asRecords`). */ +export function recObjs(obj: JsonRecord | undefined, key: string): JsonRecord[] { + return asRecords(obj?.[key]); +} +/** + * A counter field (token usage, quota balance). Unlike `recNum` this accepts a + * numeric string, because several providers serialize counts as `"123"` and a + * usage row that reads 0 for a real response is worse than a coerced guess. + */ +export function recCount(obj: JsonRecord | undefined, key: string): number | undefined { + const value = obj?.[key]; + if (value === undefined || value === null || value === "") return undefined; + const num = typeof value === "number" ? value : Number(value); + return Number.isFinite(num) ? num : undefined; +} + +/** Best-effort JSON parse of a wire payload; malformed or absent input becomes `{}` rather than throwing. */ +export function parse(value: unknown): JsonValue { + try { return (typeof value === "string" ? JSON.parse(value) : value ?? {}) as JsonValue; } catch { return {}; } +} diff --git a/src/process.ts b/src/process.ts index 1c166f0..307c6fb 100644 --- a/src/process.ts +++ b/src/process.ts @@ -137,7 +137,7 @@ const SIGNAL_NUMBERS: Record = { SIGHUP: 1, SIGINT: 2, SIGQUIT: /** * Thrown when the client executable itself cannot be spawned — most commonly * because it is not installed or not on PATH (`spawn x ENOENT`). Carries the - * executable name so callers can offer an actionable recovery flow. + * executable name so callers can print an actionable install hint. */ export class ClientNotFoundError extends Error { constructor(readonly executable: string) { @@ -152,17 +152,6 @@ export const CLIENT_INSTALL_COMMANDS: Record = { codex: "npm install -g @openai/codex", }; -/** Run a command through the user's shell with inherited stdio; resolves with the exit code. - * Spawn failures (e.g. no shell) resolve to 1 instead of rejecting, so the caller's - * install-recovery branch stays in charge of reporting. */ -export function runShellCommand(command: string): Promise { - const child = spawn(command, { stdio: "inherit", shell: true }); - return new Promise((resolve) => { - child.once("error", () => resolve(1)); - child.once("exit", (code) => resolve(code ?? 1)); - }); -} - /** * Windows-only: runCommand spawns with shell:true so npm's .cmd shims resolve * (plain spawn() without a shell can't find them by extension-less name), but diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e6ebee9..9c39ec3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,3 +1,5 @@ +import type { JsonRecord, JsonValue } from "../json.js"; +import { jsonRecord, recNum, recObj, recObjs, recStr } from "../json.js"; import type { ProviderDefinition, ProviderModel } from "./types.js"; import { loadOpenCodeModels, loadOpenRouterModels, saveOpenCodeModels, saveOpenRouterModels } from "../runtime.js"; @@ -48,15 +50,24 @@ interface ModelMetadata { contextWindow?: number; maxOutputTokens?: number; moda type MetadataSections = Map; type MetadataMap = Map; -function parseModelsDevMetadata(payload: any): MetadataSections { +/** The only modalities the rest of the codebase distinguishes; anything else is dropped. */ +function knownModalities(value: JsonValue | undefined): string[] { + return (Array.isArray(value) ? value : []).filter((item): item is "text" | "image" => item === "text" || item === "image"); +} + +function parseModelsDevMetadata(payload: JsonRecord): MetadataSections { const sections: MetadataSections = new Map(); - for (const [sectionId, definition] of Object.entries((payload ?? {}) as Record)) { + for (const [sectionId, definitionValue] of Object.entries(payload)) { const table: MetadataMap = new Map(); - for (const [id, entry] of Object.entries(definition?.models ?? {}) as Array<[string, any]>) { - const input = (entry?.modalities?.input ?? []).filter((item: unknown) => item === "text" || item === "image"); + for (const [id, entryValue] of Object.entries(recObj(jsonRecord(definitionValue), "models") ?? {})) { + const entry = jsonRecord(entryValue); + const limit = recObj(entry, "limit"); + const contextWindow = recNum(limit, "context"); + const maxOutputTokens = recNum(limit, "output"); + const input = knownModalities(recObj(entry, "modalities")?.input); table.set(id, { - ...(Number.isFinite(entry?.limit?.context) ? { contextWindow: Number(entry.limit.context) } : {}), - ...(Number.isFinite(entry?.limit?.output) ? { maxOutputTokens: Number(entry.limit.output) } : {}), + ...(contextWindow === undefined ? {} : { contextWindow }), + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), ...(input.length ? { modalities: input } : {}), }); } @@ -66,17 +77,20 @@ function parseModelsDevMetadata(payload: any): MetadataSections { } /** Flatten OpenRouter's public model list into a single table keyed by its vendor-prefixed id. */ -function parseOpenRouterMetadata(payload: any): MetadataMap { +function parseOpenRouterMetadata(payload: JsonRecord): MetadataMap { const table: MetadataMap = new Map(); - for (const entry of ((payload?.data ?? []) as Array)) { - if (!entry?.id) continue; - const input = (entry?.architecture?.input_modalities ?? []).filter((item: unknown) => item === "text" || item === "image"); + for (const entry of recObjs(payload, "data")) { + const id = recStr(entry, "id"); + if (!id) continue; + const input = knownModalities(recObj(entry, "architecture")?.input_modalities); + const contextWindow = recNum(entry, "context_length"); + const maxOutputTokens = recNum(recObj(entry, "top_provider"), "max_completion_tokens"); const meta: ModelMetadata = { - ...(Number.isFinite(entry?.context_length) ? { contextWindow: Number(entry.context_length) } : {}), - ...(Number.isFinite(entry?.top_provider?.max_completion_tokens) ? { maxOutputTokens: Number(entry.top_provider.max_completion_tokens) } : {}), + ...(contextWindow === undefined ? {} : { contextWindow }), + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), ...(input.length ? { modalities: input } : {}), }; - if (Object.keys(meta).length) table.set(entry.id, meta); + if (Object.keys(meta).length) table.set(id, meta); } return table; } @@ -133,16 +147,11 @@ export function setOpenRouterCatalogIds(ids: string[]): void { const preferred = process.env.OPENROUTER_MODEL ?? (ids.length ? ids[0] : undefined); const current = provider.models[0]?.model; if (preferred && preferred !== current) { - provider.models = [models("openrouter", `${openRouterBase}/chat/completions`, [preferred], "chat-completions")[0]]; + provider.models = [{ provider: "openrouter", model: preferred, protocol: "chat-completions", endpoint: `${openRouterBase}/chat/completions` }]; rebuildAllModels(); } } -/** Isolation seam so tests can restore the module-level model cache. */ -export function setCachedOpenRouter(table: MetadataMap): void { - cachedOpenRouter = table; -} - /** * Fetch OpenRouter's auth-free public catalog for picker/search use. Failures * keep the previously cached ids so a flaky network degrades to the last good @@ -154,9 +163,9 @@ export async function fetchOpenRouterModels( try { const response = await fetcher(openRouterModelsUrl, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000) }); if (!response.ok) return cachedOpenRouterIds; - const payload = await response.json(); - const ids = ((payload?.data ?? []) as Array<{ id?: string }>) - .map((entry) => entry.id) + const payload = jsonRecord(await response.json()); + const ids = recObjs(payload, "data") + .map((entry) => recStr(entry, "id")) .filter((id): id is string => Boolean(id)); if (!ids.length) return cachedOpenRouterIds; cachedOpenRouterIds = ids; @@ -310,6 +319,9 @@ export function isPlaceholderModel(model: Pick entry.id === base && entry.custom); let id = base; @@ -340,7 +352,7 @@ export function registerCustomProvider(input: CustomProviderInput): ProviderDefi while (providerRegistry.some((entry) => entry.id === id)) id = `${base}-${suffix++}`; } const model: ProviderModel = { provider: id, model: input.model ?? CUSTOM_PROVIDER_PLACEHOLDER_MODEL, protocol: input.protocol, endpoint: customProviderEndpoint(input.baseUrl, input.protocol), ...(input.headers && Object.keys(input.headers).length ? { headers: input.headers } : {}) }; - const definition: ProviderDefinition = { id, name: input.name, apiKeyEnv: envKeyFor(id), models: [model], custom: true }; + const definition: CustomProviderDefinition = { id, name: input.name, apiKeyEnv: envKeyFor(id), models: [model], custom: true }; const index = providerRegistry.findIndex((entry) => entry.id === id); if (index >= 0) providerRegistry[index] = definition; else providerRegistry.push(definition); @@ -386,19 +398,19 @@ export async function refreshProviderCatalog( const [list, sections, openRouter] = await Promise.all([ needList ? fetcher(`${openCodeBase}/models`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5000) }) - .then((response) => (response.ok ? response.json() : null)).catch(() => null) + .then((response) => (response.ok ? jsonRecord(response.json()) : null)).catch((): JsonRecord | null => null) : Promise.resolve(null), needMetadata ? fetcher(modelsDevUrl, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000) }) - .then((response) => (response.ok ? response.json() : null)).then(parseModelsDevMetadata) + .then((response) => (response.ok ? response.json() : null)).then((payload) => parseModelsDevMetadata(jsonRecord(payload))) .catch((): MetadataSections => new Map()) : Promise.resolve(new Map()), needMetadata ? fetcher(openRouterModelsUrl, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000) }) - .then((response) => (response.ok ? response.json() : null)).then(parseOpenRouterMetadata) + .then((response) => (response.ok ? response.json() : null)).then((payload) => parseOpenRouterMetadata(jsonRecord(payload))) .catch((): MetadataMap => new Map()) : Promise.resolve(new Map()), - ]) as [any, MetadataSections, MetadataMap]; + ]); let refreshedMetadata = false; if (needMetadata) { @@ -418,7 +430,7 @@ export async function refreshProviderCatalog( let refreshedList = false; if (!list) return { list: false, metadata: refreshedMetadata }; - const ids = ((list.data ?? []) as Array<{ id?: string }>).map((item) => item.id).filter((id): id is string => Boolean(id)); + const ids = recObjs(list, "data").map((item) => recStr(item, "id")).filter((id): id is string => Boolean(id)); if (ids.length) { const openCode = providerRegistry.find((provider) => provider.id === "opencode"); if (openCode) { diff --git a/src/providers/usage/anthropic.ts b/src/providers/usage/anthropic.ts index f71cd0c..6b4dc77 100644 --- a/src/providers/usage/anthropic.ts +++ b/src/providers/usage/anthropic.ts @@ -1,4 +1,6 @@ import type { TokenUsage, UsageContext } from "../../usage/types.js"; +import type { JsonRecord } from "../../json.js"; +import { recCount, recObj } from "../../json.js"; /** * Anthropic Messages API usage fields: { input_tokens, output_tokens, @@ -6,25 +8,26 @@ import type { TokenUsage, UsageContext } from "../../usage/types.js"; * the streaming pipes in src/streaming/common.ts can reuse this exact field * list to pull cache tokens out of a raw usage chunk. */ -export function mapAnthropicUsage(usage: any, ctx: UsageContext): TokenUsage | null { - if (!usage || typeof usage !== "object") return null; - const inputTokens = Number(usage.input_tokens ?? 0); - const outputTokens = Number(usage.output_tokens ?? 0); - const result: TokenUsage = { +export function mapAnthropicUsage(usage: JsonRecord | undefined, ctx: UsageContext): TokenUsage | null { + if (!usage) return null; + const inputTokens = recCount(usage, "input_tokens") ?? 0; + const outputTokens = recCount(usage, "output_tokens") ?? 0; + const cachedInputTokens = recCount(usage, "cache_read_input_tokens"); + const cacheWriteTokens = recCount(usage, "cache_creation_input_tokens"); + return { provider: ctx.provider ?? "unknown", model: ctx.model ?? "unknown", inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, ...(ctx.sessionId === undefined ? {} : { sessionId: ctx.sessionId }), - ...(ctx.timestamp === undefined ? {} : { timestamp: ctx.timestamp }) + ...(ctx.timestamp === undefined ? {} : { timestamp: ctx.timestamp }), + ...(cachedInputTokens === undefined ? {} : { cachedInputTokens }), + ...(cacheWriteTokens === undefined ? {} : { cacheWriteTokens }), }; - if (usage.cache_read_input_tokens !== undefined && usage.cache_read_input_tokens !== null) result.cachedInputTokens = Number(usage.cache_read_input_tokens); - if (usage.cache_creation_input_tokens !== undefined && usage.cache_creation_input_tokens !== null) result.cacheWriteTokens = Number(usage.cache_creation_input_tokens); - return result; } /** Anthropic Messages API usage: { usage: { input_tokens, output_tokens, cache_*_tokens } } */ -export function extractAnthropicUsage(response: any, ctx: UsageContext): TokenUsage | null { - return mapAnthropicUsage(response?.usage, ctx); +export function extractAnthropicUsage(response: JsonRecord, ctx: UsageContext): TokenUsage | null { + return mapAnthropicUsage(recObj(response, "usage"), ctx); } diff --git a/src/providers/usage/index.ts b/src/providers/usage/index.ts index 47780d3..9788b5a 100644 --- a/src/providers/usage/index.ts +++ b/src/providers/usage/index.ts @@ -1,5 +1,6 @@ import type { ProviderModel } from "../types.js"; import type { TokenUsage, UsageContext } from "../../usage/types.js"; +import type { JsonRecord } from "../../json.js"; import { extractResponsesUsage, extractChatUsage, mapResponsesUsage, mapChatUsage } from "./openai.js"; import { extractAnthropicUsage, mapAnthropicUsage } from "./anthropic.js"; @@ -9,7 +10,7 @@ export type { TokenUsage, UsageContext } from "../../usage/types.js"; // own copy of these field names. export { mapResponsesUsage, mapChatUsage, mapAnthropicUsage }; -export function extractUsage(response: any, model: ProviderModel, ctx: UsageContext): TokenUsage | null { +export function extractUsage(response: JsonRecord, model: ProviderModel, ctx: UsageContext): TokenUsage | null { const context = { ...ctx, provider: model.provider, model: model.model }; switch (model.protocol) { case "responses": return extractResponsesUsage(response, context); diff --git a/src/providers/usage/openai.ts b/src/providers/usage/openai.ts index c2e6ab5..0548435 100644 --- a/src/providers/usage/openai.ts +++ b/src/providers/usage/openai.ts @@ -1,7 +1,8 @@ import type { TokenUsage, UsageContext } from "../../usage/types.js"; +import type { JsonRecord } from "../../json.js"; +import { recCount, recObj } from "../../json.js"; -function base(usage: any, ctx: UsageContext): TokenUsage | null { - if (!usage || typeof usage !== "object") return null; +function base(ctx: UsageContext): TokenUsage { return { provider: ctx.provider ?? "unknown", model: ctx.model ?? "unknown", @@ -21,37 +22,37 @@ function base(usage: any, ctx: UsageContext): TokenUsage | null { * cache/reasoning tokens out of a raw usage chunk, instead of keeping a * second, independently-maintained copy of these field names. */ -function mapUsage(usage: any, ctx: UsageContext, names: { +function mapUsage(usage: JsonRecord | undefined, ctx: UsageContext, names: { input: string; output: string; cached: string; reasoning: string; }): TokenUsage | null { - const result = base(usage, ctx); - if (!result) return null; - result.inputTokens = Number(usage[names.input] ?? 0); - result.outputTokens = Number(usage[names.output] ?? 0); - result.totalTokens = Number(usage.total_tokens ?? (result.inputTokens + result.outputTokens)); - const cached = usage[names.cached]?.cached_tokens; - if (cached !== undefined && cached !== null) result.cachedInputTokens = Number(cached); - const reasoning = usage[names.reasoning]?.reasoning_tokens; - if (reasoning !== undefined && reasoning !== null) result.reasoningTokens = Number(reasoning); + if (!usage) return null; + const result = base(ctx); + result.inputTokens = recCount(usage, names.input) ?? 0; + result.outputTokens = recCount(usage, names.output) ?? 0; + result.totalTokens = recCount(usage, "total_tokens") ?? (result.inputTokens + result.outputTokens); + const cached = recCount(recObj(usage, names.cached), "cached_tokens"); + if (cached !== undefined) result.cachedInputTokens = cached; + const reasoning = recCount(recObj(usage, names.reasoning), "reasoning_tokens"); + if (reasoning !== undefined) result.reasoningTokens = reasoning; return result; } /** OpenAI Responses API usage fields: { input_tokens, output_tokens, ... }. Takes the bare usage object, not a response envelope. */ -export function mapResponsesUsage(usage: any, ctx: UsageContext): TokenUsage | null { +export function mapResponsesUsage(usage: JsonRecord | undefined, ctx: UsageContext): TokenUsage | null { return mapUsage(usage, ctx, { input: "input_tokens", output: "output_tokens", cached: "input_tokens_details", reasoning: "output_tokens_details" }); } /** OpenAI Chat Completions usage fields: { prompt_tokens, completion_tokens, ... }. Takes the bare usage object, not a response envelope. */ -export function mapChatUsage(usage: any, ctx: UsageContext): TokenUsage | null { +export function mapChatUsage(usage: JsonRecord | undefined, ctx: UsageContext): TokenUsage | null { return mapUsage(usage, ctx, { input: "prompt_tokens", output: "completion_tokens", cached: "prompt_tokens_details", reasoning: "completion_tokens_details" }); } /** OpenAI Responses API usage: { input_tokens, output_tokens, ... } */ -export function extractResponsesUsage(response: any, ctx: UsageContext): TokenUsage | null { - return mapResponsesUsage(response?.usage, ctx); +export function extractResponsesUsage(response: JsonRecord, ctx: UsageContext): TokenUsage | null { + return mapResponsesUsage(recObj(response, "usage"), ctx); } /** OpenAI Chat Completions usage: { prompt_tokens, completion_tokens, ... } */ -export function extractChatUsage(response: any, ctx: UsageContext): TokenUsage | null { - return mapChatUsage(response?.usage, ctx); +export function extractChatUsage(response: JsonRecord, ctx: UsageContext): TokenUsage | null { + return mapChatUsage(recObj(response, "usage"), ctx); } diff --git a/src/quota.ts b/src/quota.ts index 5a772ac..4ddb528 100644 --- a/src/quota.ts +++ b/src/quota.ts @@ -1,3 +1,5 @@ +import { jsonRecord, recCount, recObj, recStr, recObjs } from "./json.js"; +import type { JsonRecord } from "./json.js"; import { providerById } from "./providers/registry.js"; import { resolveCredential } from "./credentials.js"; @@ -13,17 +15,17 @@ export interface UsageResult { message?: string; } -export function parseDeepSeekBalance(payload: any): UsageResult { - const balance = payload?.balance_infos?.[0]; +export function parseDeepSeekBalance(payload: JsonRecord): UsageResult { + const balance = recObjs(payload, "balance_infos")[0]; if (!balance) return { provider: "deepseek", supported: true, success: false, message: "DeepSeek returned no balance information." }; - return { provider: "deepseek", supported: true, success: true, remaining: Number(balance.total_balance ?? 0), unit: balance.currency ?? "CNY" }; + return { provider: "deepseek", supported: true, success: true, remaining: recCount(balance, "total_balance") ?? 0, unit: recStr(balance, "currency") ?? "CNY" }; } -export function parseOpenRouterKey(payload: any): UsageResult { - const data = payload?.data ?? {}; - const used = Number(data.usage ?? 0); - const limit = data.limit == null ? undefined : Number(data.limit); - const remaining = data.limit_remaining == null ? (limit === undefined ? undefined : limit - used) : Number(data.limit_remaining); +export function parseOpenRouterKey(payload: JsonRecord): UsageResult { + const data = recObj(payload, "data") ?? {}; + const used = recCount(data, "usage") ?? 0; + const limit = recCount(data, "limit"); + const remaining = recCount(data, "limit_remaining") ?? (limit === undefined ? undefined : limit - used); return { provider: "openrouter", supported: true, success: true, used, ...(limit === undefined ? {} : { total: limit }), ...(remaining === undefined ? {} : { remaining }), unit: "USD" }; } @@ -38,12 +40,12 @@ export async function queryProviderUsage(providerId: string, apiKey: string): Pr } catch (error) { return { provider: providerId, supported: true, success: false, message: `Failed to reach ${providerId}: ${error instanceof Error ? error.message : error}` }; } - const payload = await response.json().catch(() => ({})); - if (!response.ok) return { provider: providerId, supported: true, success: false, message: payload?.error?.message ?? `Provider returned HTTP ${response.status}.` }; + const payload = jsonRecord(await response.json().catch(() => ({}))); + if (!response.ok) return { provider: providerId, supported: true, success: false, message: recStr(recObj(payload, "error"), "message") ?? `Provider returned HTTP ${response.status}.` }; return providerId === "deepseek" ? parseDeepSeekBalance(payload) : parseOpenRouterKey(payload); } -export function usageProvider(id?: string) { return providerById(id ?? process.env.AGENTX_PROVIDER ?? "opencode"); } +function usageProvider(id?: string) { return providerById(id ?? process.env.AGENTX_PROVIDER ?? "opencode"); } /** Resolve credentials, query quota, and format the result; shared by `agentx quota` and the deprecated `agentx usage --provider`. */ export async function runQuotaCommand(providerId: string | undefined): Promise<{ output: string; exitCode: number }> { diff --git a/src/runtime.ts b/src/runtime.ts index 0e4da2f..08437db 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -292,7 +292,7 @@ export async function saveSessionRecord(sessionId: string, record: SessionRecord const sessions: Record = { ...state.sessions, [sessionId]: record }; const ids = Object.keys(sessions); if (ids.length > MAX_REMEMBERED_SESSIONS) { - const oldest = ids.sort((a, b) => sessions[a].recordedAt - sessions[b].recordedAt).slice(0, ids.length - MAX_REMEMBERED_SESSIONS); + const oldest = ids.sort((a, b) => (sessions[a]?.recordedAt ?? 0) - (sessions[b]?.recordedAt ?? 0)).slice(0, ids.length - MAX_REMEMBERED_SESSIONS); for (const id of oldest) delete sessions[id]; } state.sessions = sessions; diff --git a/src/server.ts b/src/server.ts index 9869b06..920ebe6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,11 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import type { Config } from "./config.js"; -import { chatResponseFailure, fromAnthropicResponse, fromAnthropicResponseToChat, fromChatResponse, fromChatResponseToResponses, fromResponsesResponse, fromResponsesResponseToChat, responsesResponseFailure, toAnthropicRequest, toAnthropicRequestFromChat, toChatCompletionsRequest, toChatRequest, toResponsesRequest, toResponsesRequestFromChat } from "./convert/index.js"; +import { asAnthropicRequest, chatResponseFailure, fromAnthropicResponse, fromAnthropicResponseToChat, fromChatResponse, fromChatResponseToResponses, fromResponsesResponse, fromResponsesResponseToChat, responsesResponseFailure, toAnthropicRequest, toAnthropicRequestFromChat, toChatCompletionsRequest, toChatRequest, toResponsesRequest, toResponsesRequestFromChat } from "./convert/index.js"; +import type { JsonRecord } from "./json.js"; +import { isRecord, jsonRecord, recNum, recStr } from "./json.js"; import { pipeAnthropicPassthrough, pipeAnthropicStreamToChat, pipeAnthropicStreamToResponses, pipeChatPassthrough, pipeChatStreamToResponses, pipeResponsesPassthrough, pipeResponsesStream, pipeResponsesStreamToChat, type StreamUsageOptions } from "./streaming/index.js"; -import type { ProviderModel } from "./providers/types.js"; +import type { ProviderModel, ProviderProtocol } from "./providers/types.js"; import type { TokenUsage } from "./usage/types.js"; import { honorRequestedModel, providerFor, providers } from "./catalog.js"; import { apiKeyFor, isPlaceholderModel, providerDisplayName } from "./providers/registry.js"; @@ -169,18 +171,18 @@ const IMAGE_TOKEN_ESTIMATE = 1_600; * approximation; this is explicitly an estimate, but one made from the actual * request, unlike the blind fallback a 404 used to force on the client. */ -export function estimateInputTokens(input: any): number { +export function estimateInputTokens(input: JsonRecord): number { let chars = 0; const walk = (value: unknown): void => { if (typeof value === "string") { chars += value.length; return; } if (Array.isArray(value)) { value.forEach(walk); return; } - if (!value || typeof value !== "object") return; + if (!isRecord(value)) return; // An image block's base64 payload is not prose; charge it a flat rate // instead of walking into the data string. - if ((value as { type?: string }).type === "image") { chars += IMAGE_TOKEN_ESTIMATE * 4; return; } + if (value.type === "image") { chars += IMAGE_TOKEN_ESTIMATE * 4; return; } Object.values(value).forEach(walk); }; - walk(input?.system); walk(input?.messages); walk(input?.tools); + walk(input.system); walk(input.messages); walk(input.tools); return Math.max(1, Math.ceil(chars / 4)); } @@ -194,8 +196,8 @@ async function upstreamTokenCount(provider: ProviderModel, apiKey: string, paylo try { const upstream = await fetch(`${provider.endpoint}/count_tokens`, { method: "POST", headers: authHeaders(provider, apiKey), body: JSON.stringify(payload), signal: AbortSignal.timeout(10_000) }); if (!upstream.ok) return undefined; - const value = await upstream.json(); - return Number.isFinite(value?.input_tokens) ? Number(value.input_tokens) : undefined; + const value = jsonRecord(await upstream.json()); + return recNum(value, "input_tokens"); } catch { return undefined; } } @@ -256,32 +258,35 @@ function watchedBody(stream: ReadableStream | null, progress: () => } /** - * Shared proxy pipeline for both client-facing endpoints: authenticate, parse - * the body, resolve provider/model/key, convert the payload to the upstream - * protocol, forward it with the idle watchdog, and map failures to errors. - * Returns `undefined` after answering the request directly (auth failure, - * non-OK upstream status); otherwise yields the parsed request and the - * watched upstream response. + * Shared proxy pipeline for one request: parse the body, resolve + * provider/model/key, convert the payload to the upstream protocol, forward it + * with the idle watchdog, and map failures to errors. Returns `undefined` + * after answering the request directly (non-OK upstream status); otherwise + * yields the parsed request and the watched upstream response. + * + * Callers authenticate before reaching this: the route table rejects a bad + * token ahead of the concurrency queue so an unauthorized request cannot make + * a legitimate one wait. */ async function proxyRequest( config: Config, request: IncomingMessage, response: ServerResponse, - token: string, - route: { fallbackModel: string; payload: (input: any, model: string, provider: ProviderModel) => unknown }, + fallbackModel: string, + payload: (input: JsonRecord, model: string, provider: ProviderModel) => unknown, maxBodyBytes = MAX_BODY_BYTES, -): Promise<{ input: any; model: string; provider: ProviderModel; watched: Response } | undefined> { - if (!authorized(request, token)) { unauthorized(response); return undefined; } +): Promise<{ input: JsonRecord; model: string; provider: ProviderModel; watched: Response } | undefined> { try { - const input = JSON.parse(await body(request, maxBodyBytes)); - // The endpoint decides which model id counts as "configured" (/v1/responses - // honors the client-echoed input.model; /v1/messages only config/env). - const model = honorRequestedModel(input.model, route.fallbackModel, config.provider); + const input = jsonRecord(JSON.parse(await body(request, maxBodyBytes))); + // The endpoint decides which model id counts as "configured"; clients + // that echo a preferred model (e.g. Codex sending OPENAI_MODEL=auto back) + // are honored per honorRequestedModel's rules. + const model = honorRequestedModel(recStr(input, "model"), fallbackModel, config.provider); debug(config, `POST ${request.url} model=${model}`); const provider = providerFor(model, config.provider); const apiKey = apiKeyFor(provider, config.apiKey); - const payload = route.payload(input, model, provider); + const payloadBody = payload(input, model, provider); const session = upstreamSession(response, config); - const upstream = await forwardWithRetry(config, provider, apiKey, payload, session.signal, config.retry); + const upstream = await forwardWithRetry(config, provider, apiKey, payloadBody, session.signal, config.retry); session.progress(); debug(config, `provider status=${upstream.status}`); if (!upstream.ok) { await upstreamError(response, providerDisplayName(provider.provider), upstream, upstream.status); return undefined; } @@ -294,6 +299,74 @@ async function proxyRequest( } } +/** + * Per client-facing endpoint: how to build the upstream payload for each + * provider protocol, which pipe serves each streaming combination, and how + * to translate a non-streaming upstream JSON back to this endpoint's format. + * Invariant: the pipe/translator selected always matches the protocol whose + * payload builder ran (e.g. a 200 with no body on an Anthropic upstream goes + * to the Anthropic pipe, never the Responses one). + */ +interface EndpointSpec { + payloads: Record unknown>; + pipes: Record Promise>; + /** Non-stream JSON translation; an upstream-reported failure maps to a 502 instead. */ + finish(upstream: JsonRecord, provider: ProviderModel, model: string): unknown; + /** In-band failure message carried by a non-OK-shaped 200 payload, if any. */ + failure?(upstream: JsonRecord, provider: ProviderModel): string | undefined; +} + +const ENDPOINT_SPECS: Record<"responses" | "chat" | "messages", EndpointSpec> = { + responses: { + payloads: { + "responses": (input, model) => ({ ...input, model }), + "chat-completions": (input, model, provider) => toChatCompletionsRequest(input, model, provider.provider), + "anthropic": (input, model) => toAnthropicRequest(input, model), + }, + pipes: { + "chat-completions": pipeChatStreamToResponses, + "responses": pipeResponsesPassthrough, + "anthropic": pipeAnthropicStreamToResponses, + }, + finish: (upstream, provider, model) => provider.protocol === "anthropic" + ? fromAnthropicResponse(upstream, model) + : provider.protocol === "chat-completions" ? fromChatResponseToResponses(upstream, model) : upstream, + failure: (upstream, provider) => provider.protocol === "chat-completions" ? chatResponseFailure(upstream) : provider.protocol === "responses" ? responsesResponseFailure(upstream) : undefined, + }, + chat: { + payloads: { + "responses": (input, model) => toResponsesRequestFromChat(input, model), + "chat-completions": (input, model) => ({ ...input, model }), + "anthropic": (input, model) => toAnthropicRequestFromChat(input, model), + }, + pipes: { + "chat-completions": pipeChatPassthrough, + "responses": pipeResponsesStreamToChat, + "anthropic": pipeAnthropicStreamToChat, + }, + finish: (upstream, provider, model) => provider.protocol === "anthropic" + ? fromAnthropicResponseToChat(upstream, model) + : provider.protocol === "responses" ? fromResponsesResponseToChat(upstream, model) : upstream, + failure: (upstream, provider) => provider.protocol === "responses" ? responsesResponseFailure(upstream) : provider.protocol === "chat-completions" ? chatResponseFailure(upstream) : undefined, + }, + messages: { + payloads: { + "responses": (input, model, provider) => toResponsesRequest(asAnthropicRequest(input), model, provider), + "chat-completions": (input, model, provider) => toChatRequest(asAnthropicRequest(input), model, provider), + // already Anthropic-shaped; zero conversion + "anthropic": (input, model) => ({ ...input, model }), + }, + pipes: { + "chat-completions": pipeResponsesStream, + "responses": pipeResponsesStream, + "anthropic": pipeAnthropicPassthrough, + }, + // Anthropic upstreams are already Anthropic-shaped; others convert back. + finish: (upstream, provider) => provider.protocol === "anthropic" ? upstream : provider.protocol === "responses" ? fromResponsesResponse(upstream, provider.model) : fromChatResponse(upstream, provider.model), + failure: (upstream, provider) => provider.protocol === "chat-completions" ? chatResponseFailure(upstream) : provider.protocol === "responses" ? responsesResponseFailure(upstream) : undefined, + }, +}; + export async function startAdapter(config: Config, options: AdapterOptions = {}): Promise { const initialModel = config.model; const initialProvider = providerFor(initialModel, config.provider); const initialApiKey = apiKeyFor(initialProvider, config.apiKey); @@ -307,18 +380,17 @@ export async function startAdapter(config: Config, options: AdapterOptions = {}) const closeGraceMs = options.closeGraceMs ?? 3_000; const sessionId = randomUUID(); const gate = createConcurrencyGate(config.maxConcurrency ?? 0); - const sessionFor = (input: any) => typeof input?.session_id === "string" ? input.session_id : sessionId; + const sessionFor = (input: JsonRecord) => recStr(input, "session_id") ?? sessionId; /** Record one usage row, swallowing persistence errors into debug logs. */ const safeRecord = (usage: TokenUsage) => { void collector.record(usage).catch((error) => debug(config, `usage record error=${error instanceof Error ? error.message : "unknown"}`)); }; - const recordUsage = (value: any, provider: ProviderModel, session: string) => { const usage = extractUsage(value, provider, { sessionId: session }); if (usage) safeRecord(usage); }; + const recordUsage = (value: JsonRecord, provider: ProviderModel, session: string) => { const usage = extractUsage(value, provider, { sessionId: session }); if (usage) safeRecord(usage); }; const handleRequest = async (request: IncomingMessage, response: ServerResponse): Promise => { debug(config, `${request.method} ${request.url}`); const url = new URL(request.url ?? "/", "http://localhost"); const pathname = url.pathname; if (pathname === "/health" && request.method === "GET") return json(response, 200, { status: "ok" }); // Usage statistics are read through `agentx usage` (direct storage access); - // the former unauthenticated /usage/* HTTP endpoints were removed — see - // docs/remaining-simplification-todos.md item B. + // the former unauthenticated /usage/* HTTP endpoints were removed. // The model catalog reveals which provider and models this machine is // configured for, so it is authenticated like the proxy routes. /health // stays open: it carries no configuration and is what a supervisor polls. @@ -338,8 +410,8 @@ export async function startAdapter(config: Config, options: AdapterOptions = {}) if (pathname === "/v1/messages/count_tokens" && request.method === "POST") { if (!authorized(request, token)) return unauthorized(response); try { - const input = JSON.parse(await body(request, maxBodyBytes)); - const model = honorRequestedModel(input.model, config.model, config.provider); + const input = jsonRecord(JSON.parse(await body(request, maxBodyBytes))); + const model = honorRequestedModel(recStr(input, "model"), config.model, config.provider); const provider = providerFor(model, config.provider); // Only a native Anthropic upstream can answer this exactly; the other // protocols have no equivalent endpoint, so the estimate stands in. @@ -347,83 +419,33 @@ export async function startAdapter(config: Config, options: AdapterOptions = {}) return json(response, 200, { input_tokens: exact ?? estimateInputTokens(input) }); } catch (error) { return respondError(response, error); } } + const specFor = pathname === "/v1/responses" ? ENDPOINT_SPECS.responses + : pathname === "/v1/chat/completions" ? ENDPOINT_SPECS.chat + : pathname === "/v1/messages" ? ENDPOINT_SPECS.messages + : undefined; + if (!specFor || request.method !== "POST") return json(response, 404, { error: { message: "Not found", type: "not_found" } }); + // Authentication comes first: a request that is going to be rejected must + // not sit in the queue ahead of a legitimate one. + if (!authorized(request, token)) return unauthorized(response); // Hold a concurrency slot for the whole proxied exchange, streaming // included, and free it once the response is done either way. Acquiring // after the client already left would otherwise strand the slot. - // - // Authentication comes first: a request that is going to be rejected must - // not sit in the queue ahead of a legitimate one. - if (request.method === "POST" && (pathname === "/v1/responses" || pathname === "/v1/chat/completions" || pathname === "/v1/messages")) { - if (!authorized(request, token)) return unauthorized(response); - const release = await gate.acquire(); - if (response.closed || response.writableEnded) release(); - else { response.on("close", release); response.on("finish", release); } - } - if (pathname === "/v1/responses" && request.method === "POST") { - // Honor auto routing here too: Codex echoes OPENAI_MODEL=auto back. - const proxied = await proxyRequest(config, request, response, token, { - fallbackModel: config.model, - payload: (input, model, provider) => { - if (provider.protocol === "anthropic") return toAnthropicRequest(input, model); - return provider.protocol === "responses" ? { ...input, model } : toChatCompletionsRequest(input, model, provider.provider); - }, - }, maxBodyBytes); - if (!proxied) return; - const { input, model, provider, watched } = proxied; - const options = streamOptions(config, provider, sessionFor(input), safeRecord); - if (input.stream && provider.protocol === "chat-completions") return pipeChatStreamToResponses(watched, response, model, options); - if (input.stream && provider.protocol === "anthropic") return pipeAnthropicStreamToResponses(watched, response, model, options); - if (input.stream) return pipeResponsesPassthrough(watched, response, model, options); - if (!watched.body) { response.end(); return; } - const value = await watched.json(); recordUsage(value, provider, sessionFor(input)); - if (provider.protocol === "anthropic") return json(response, watched.status, fromAnthropicResponse(value, model)); - const failure = provider.protocol === "chat-completions" ? chatResponseFailure(value) : responsesResponseFailure(value); - if (failure) return json(response, 502, { error: { message: failure, type: "upstream_error" } }); - return json(response, watched.status, provider.protocol === "chat-completions" ? fromChatResponseToResponses(value, model) : value); - } - if (pathname === "/v1/chat/completions" && request.method === "POST") { - const proxied = await proxyRequest(config, request, response, token, { - fallbackModel: config.model, - payload: (input, model, provider) => { - if (provider.protocol === "anthropic") return toAnthropicRequestFromChat(input, model); - if (provider.protocol === "responses") return toResponsesRequestFromChat(input, model); - return { ...input, model }; // already chat-completions-shaped - }, - }, maxBodyBytes); - if (!proxied) return; - const { input, model, provider, watched } = proxied; - const chatOptions = streamOptions(config, provider, sessionFor(input), safeRecord); - if (input.stream && provider.protocol === "anthropic") return pipeAnthropicStreamToChat(watched, response, model, chatOptions); - if (input.stream && provider.protocol === "responses") return pipeResponsesStreamToChat(watched, response, model, chatOptions); - if (input.stream) return pipeChatPassthrough(watched, response, model, chatOptions); - if (!watched.body) { response.end(); return; } - const value = await watched.json(); recordUsage(value, provider, sessionFor(input)); - if (provider.protocol === "anthropic") return json(response, watched.status, fromAnthropicResponseToChat(value, model)); - const failure = provider.protocol === "responses" ? responsesResponseFailure(value) : chatResponseFailure(value); - if (failure) return json(response, 502, { error: { message: failure, type: "upstream_error" } }); - return json(response, watched.status, provider.protocol === "responses" ? fromResponsesResponseToChat(value, model) : value); - } - if (pathname !== "/v1/messages" || request.method !== "POST") return json(response, 404, { error: { message: "Not found", type: "not_found" } }); - const proxied = await proxyRequest(config, request, response, token, { - // Claude Code pins every tier to the configured model, but its haiku - // background lane may carry a faster sibling (see clientEnvironment); - // honor such requests instead of forcing the main model. - fallbackModel: config.model, - payload: (input, model, provider) => { - if (provider.protocol === "anthropic") return { ...input, model }; // already Anthropic-shaped; zero conversion - return provider.protocol === "responses" ? toResponsesRequest(input, model, provider) : toChatRequest(input, model, provider); - }, - }, maxBodyBytes); + const release = await gate.acquire(); + if (response.closed || response.writableEnded) release(); + else { response.on("close", release); response.on("finish", release); } + // The endpoint decides which model id counts as "configured"; clients that + // echo a preferred model (Codex sending OPENAI_MODEL=auto, Claude Code's + // haiku background lane) are honored per honorRequestedModel's rules. + const proxied = await proxyRequest(config, request, response, config.model, (input, model, provider) => specFor.payloads[provider.protocol](input, model, provider), maxBodyBytes); if (!proxied) return; const { input, model, provider, watched } = proxied; - const messagesOptions = streamOptions(config, provider, sessionFor(input), safeRecord); - if (input.stream && provider.protocol === "anthropic") return pipeAnthropicPassthrough(watched, response, model, messagesOptions); - if (input.stream) return pipeResponsesStream(watched, response, model, messagesOptions); + const usageOptions = streamOptions(config, provider, sessionFor(input), safeRecord); + if (input.stream) return specFor.pipes[provider.protocol](watched, response, model, usageOptions); + if (!watched.body) { response.end(); return; } const value = await watched.json(); recordUsage(value, provider, sessionFor(input)); - if (provider.protocol === "anthropic") return json(response, watched.status, value); // already Anthropic-shaped - const failure = provider.protocol === "chat-completions" ? chatResponseFailure(value) : responsesResponseFailure(value); + const failure = specFor.failure?.(value, provider); if (failure) return json(response, 502, { error: { message: failure, type: "upstream_error" } }); - return json(response, watched.status, provider.protocol === "responses" ? fromResponsesResponse(value, model) : fromChatResponse(value, model)); + return json(response, watched.status, specFor.finish(value, provider, model)); }; const server = createServer((request, response) => { // Last-resort guard: a rejection escaping the per-route handling (an diff --git a/src/sessions.ts b/src/sessions.ts index 59c8d21..c300b57 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -20,7 +20,7 @@ function uuidOrUndefined(value: string | undefined): string | undefined { export function resumeSessionId(client: "claude" | "codex", commandArgs: string[]): string | undefined { if (client === "claude") { for (let i = 0; i < commandArgs.length; i++) { - const arg = commandArgs[i]; + const arg = commandArgs[i] ?? ""; if (arg === "--resume" || arg === "-r") return uuidOrUndefined(commandArgs[i + 1]); if (arg.startsWith("--resume=")) return uuidOrUndefined(arg.slice("--resume=".length)); } diff --git a/src/shell-profile.ts b/src/shell-profile.ts index 0734db2..e2cc990 100644 --- a/src/shell-profile.ts +++ b/src/shell-profile.ts @@ -45,6 +45,7 @@ export function parseCredentialExports(content: string): Map { const block = /# >>> agentx credentials: ([A-Za-z_][A-Za-z0-9_]*) >>>\nexport ([A-Za-z_][A-Za-z0-9_]*)=('(?:[^']|'\\'')*')\n# <<< agentx credentials: ([A-Za-z_][A-Za-z0-9_]*) << { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; +export async function pipeAnthropicPassthrough(upstream: Response, response: ServerResponse, _model: string, options?: StreamUsageOptions) { + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, options?.protocol ?? "anthropic", !!options); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + try { + const item = jsonRecord(JSON.parse(value)); + if (item.type === "message_start") { const startUsage = recObj(recObj(item, "message"), "usage"); if (startUsage) usage.usage(startUsage); } + // message_delta is the turn's final usage; presence of options gates + // capture exactly as before (no options → nothing to report to). + if (item.type === "message_delta") { const deltaUsage = recObj(item, "usage"); if (deltaUsage && options) usage.usage(deltaUsage); } + } catch { /* Ignore incomplete provider events. */ } + }; try { - const item = JSON.parse(value); - if (item.type === "message_start" && item.message?.usage) { - inputTokens = item.message.usage.input_tokens ?? inputTokens; - outputTokens = item.message.usage.output_tokens ?? outputTokens; - } - if (item.type === "message_delta" && item.usage && options) { - outputTokens = item.usage.output_tokens ?? outputTokens; - lastUsage = item.usage; - usage = withCacheTokens({ provider: options.provider, model, inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, ...(options.sessionId ? { sessionId: options.sessionId } : {}) }, item.usage); - } - } catch { /* Ignore incomplete provider events. */ } - }; - try { - // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. - await drain(reader, consume, (chunk) => response.write(chunk)); - } catch (error) { - event(response, "error", { type: "error", error: { type: "api_error", message: errorMessage(error) } }); - } - response.end(); - reportUsage(options, usage !== null, (usage as TokenUsage | null)?.inputTokens ?? inputTokens, (usage as TokenUsage | null)?.outputTokens ?? outputTokens, lastUsage); - } finally { stopHeartbeat(); } + // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. + await drain(reader, consume, (chunk) => response.write(chunk)); + } catch (error) { + emitAnthropicError(response, error); + } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/anthropic-to-chat.ts b/src/streaming/anthropic-to-chat.ts index 870a31f..bb84550 100644 --- a/src/streaming/anthropic-to-chat.ts +++ b/src/streaming/anthropic-to-chat.ts @@ -1,5 +1,10 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { + createChatEmitter, dataLine, drain, emitChatError, emitChatToolDelta, + failureMessage, newUsageCapture, reportUsage, usageCapture, withSsePipe, UpstreamFailure, + type ChatToolCallState, type StreamUsageOptions, +} from "./common.js"; +import { jsonRecord, recNum, recObj, recStr } from "../json.js"; /** * Pipe a native Anthropic Messages SSE stream into OpenAI-style @@ -11,67 +16,66 @@ import { cancelOnDisconnect, drain, errorMessage, reportUsage, SSE_HEADERS, star * since the chunk grammar has no announce/done event pair to satisfy. */ export async function pipeAnthropicStreamToChat(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - const stopHeartbeat = startHeartbeat(response); - try { - const id = `chatcmpl_${crypto.randomUUID()}`; const created = Math.floor(Date.now() / 1000); - let inputTokens = 0; let outputTokens = 0; let sawUsage = false; let truncated = false; let lastUsage: any; - let sawMessageStop = false; - const calls = new Map(); - const chunk = (delta: Record, finishReason: string | null = null) => { - response.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`); - }; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; - try { - const item = JSON.parse(value); - if (item.type === "error") throw new UpstreamFailure(item.error?.message ?? "Upstream stream failed"); - if (item.type === "message_start" && item.message?.usage) { - inputTokens = item.message.usage.input_tokens ?? inputTokens; - outputTokens = item.message.usage.output_tokens ?? outputTokens; - } - if (item.type === "content_block_start" && item.content_block?.type === "tool_use") { - calls.set(item.index, { id: item.content_block.id ?? `call_${item.index}`, name: item.content_block.name ?? "", announced: false }); - } - if (item.type === "content_block_delta") { - const delta = item.delta ?? {}; - if (delta.type === "text_delta" && typeof delta.text === "string") { outputTokens++; chunk({ content: delta.text }); } - else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { chunk({ reasoning_content: delta.thinking }); } - else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { - const call = calls.get(item.index); - if (call) { - const toolCall: Record = { index: item.index, function: { arguments: delta.partial_json } }; - if (!call.announced) { call.announced = true; toolCall.id = call.id; toolCall.type = "function"; (toolCall.function as any).name = call.name; } - chunk({ tool_calls: [toolCall] }); + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, "anthropic"); + let truncated = false; + // A native Anthropic upstream must close with message_stop; seeing + // neither it nor an in-band error means the connection dropped mid-turn. + let sawMessageStop = false; + const calls = new Map(); + const chunk = createChatEmitter(response, model); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + try { + const item = jsonRecord(JSON.parse(value)); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + const index = recNum(item, "index") ?? 0; + const startUsage = recObj(recObj(item, "message"), "usage"); + if (item.type === "message_start" && startUsage) usage.usage(startUsage); + const contentBlock = recObj(item, "content_block"); + if (item.type === "content_block_start" && contentBlock?.type === "tool_use") { + calls.set(index, { id: recStr(contentBlock, "id") ?? `call_${index}`, name: recStr(contentBlock, "name") ?? "", announced: false }); + } + const delta = recObj(item, "delta"); + if (item.type === "content_block_delta") { + const deltaType = recStr(delta, "type"); + const text = recStr(delta, "text"); + const thinking = recStr(delta, "thinking"); + const partialJson = recStr(delta, "partial_json"); + if (deltaType === "text_delta" && text !== undefined) { capture.output++; chunk({ content: text }); } + else if (deltaType === "thinking_delta" && thinking !== undefined) { chunk({ reasoning_content: thinking }); } + else if (deltaType === "input_json_delta" && partialJson !== undefined) { + const call = calls.get(index); + if (call) emitChatToolDelta(chunk, call, index, partialJson); } } + if (item.type === "message_delta") { + if (recStr(recObj(item, "delta"), "stop_reason") === "max_tokens") truncated = true; + const deltaUsage = recObj(item, "usage"); + if (deltaUsage) usage.usage(deltaUsage); + } + if (item.type === "message_stop") sawMessageStop = true; + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - if (item.type === "message_delta") { - if (item.delta?.stop_reason === "max_tokens") truncated = true; - if (item.usage) { outputTokens = item.usage.output_tokens ?? outputTokens; lastUsage = item.usage; sawUsage = true; } + }; + try { + await drain(reader, consume); + if (!sawMessageStop) { + const message = "Upstream Anthropic stream ended before message_stop was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } - if (item.type === "message_stop") sawMessageStop = true; + chunk({}, calls.size ? "tool_calls" : truncated ? "length" : "stop"); + response.write("data: [DONE]\n\n"); } catch (error) { - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (!sawMessageStop) { - const message = "Upstream Anthropic stream ended before message_stop was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); + emitChatError(response, chunk, error); } - chunk({}, calls.size ? "tool_calls" : truncated ? "length" : "stop"); - response.write("data: [DONE]\n\n"); - } catch (error) { - chunk({ content: `\n[error: ${errorMessage(error)}]` }, "stop"); - response.write("data: [DONE]\n\n"); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/anthropic-to-responses.ts b/src/streaming/anthropic-to-responses.ts index 78cbdde..9e9c476 100644 --- a/src/streaming/anthropic-to-responses.ts +++ b/src/streaming/anthropic-to-responses.ts @@ -1,5 +1,11 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, event, REASONING_OUTPUT_INDEX, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { + announceMessageItem, announceReasoning, dataLine, drain, emitFunctionCallDelta, emitReasoningDelta, + emitResponsesCompleted, emitResponsesError, emitTextDelta, event, failureMessage, + newUsageCapture, reportUsage, usageCapture, withSsePipe, UpstreamFailure, + type ReasoningState, type StreamUsageOptions, +} from "./common.js"; +import { jsonRecord, recNum, recObj, recStr } from "../json.js"; /** * Pipe a native Anthropic Messages SSE stream into Codex-style Responses @@ -12,107 +18,83 @@ import { cancelOnDisconnect, drain, errorMessage, event, REASONING_OUTPUT_INDEX, * per-block indices. */ export async function pipeAnthropicStreamToResponses(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws. - const stopHeartbeat = startHeartbeat(response); - try { - const id = `resp_${crypto.randomUUID()}`; const msgId = `msg_${crypto.randomUUID()}`; - let text = ""; let inputTokens = 0; let outputTokens = 0; let sawUsage = false; let truncated = false; let lastUsage: any; - let rsId = ""; let rsText = ""; let messageAnnounced = false; - // A native Anthropic upstream must close every turn with message_stop; - // seeing neither it nor an in-band error means the connection dropped - // mid-turn and must not read back as a normal "completed" response. - let sawMessageStop = false; - const calls = new Map(); - event(response, "response.created", { type: "response.created", response: { id, object: "response", status: "in_progress", model, output: [] } }); - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; - try { - const item = JSON.parse(value); - if (item.type === "error") throw new UpstreamFailure(item.error?.message ?? "Upstream stream failed"); - if (item.type === "message_start" && item.message?.usage) { - inputTokens = item.message.usage.input_tokens ?? inputTokens; - outputTokens = item.message.usage.output_tokens ?? outputTokens; - } - if (item.type === "content_block_start") { - const blockType = item.content_block?.type; - if (blockType === "tool_use") { - const call = { id: item.content_block.id ?? `call_${item.index}`, name: item.content_block.name ?? "", arguments: "" }; - calls.set(item.index, call); - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: item.index, item: { type: "function_call", id: `fc_${item.index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); - } else if (blockType === "thinking" && !rsId) { - rsId = `rs_${crypto.randomUUID()}`; - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: rsId, summary: [] } }); - event(response, "response.reasoning_summary_part.added", { type: "response.reasoning_summary_part.added", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: "" } }); - } - } - if (item.type === "content_block_delta") { - const delta = item.delta ?? {}; - if (delta.type === "text_delta" && typeof delta.text === "string") { - if (!messageAnnounced) { - messageAnnounced = true; - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] } }); + await withSsePipe(upstream, response, async (reader) => { + const id = `resp_${crypto.randomUUID()}`; const msgId = `msg_${crypto.randomUUID()}`; + const capture = newUsageCapture(); + const usage = usageCapture(capture, "anthropic"); + let text = ""; let truncated = false; + const messageAnnounced = { done: false }; + const reasoning: ReasoningState = { id: "", text: "" }; + // A native Anthropic upstream must close every turn with message_stop; + // seeing neither it nor an in-band error means the connection dropped + // mid-turn and must not read back as a normal "completed" response. + let sawMessageStop = false; + const calls = new Map(); + event(response, "response.created", { type: "response.created", response: { id, object: "response", status: "in_progress", model, output: [] } }); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + try { + const item = jsonRecord(JSON.parse(value)); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + const startUsage = recObj(recObj(item, "message"), "usage"); + if (item.type === "message_start" && startUsage) usage.usage(startUsage); + const index = recNum(item, "index") ?? 0; + const contentBlock = recObj(item, "content_block"); + if (item.type === "content_block_start") { + const blockType = recStr(contentBlock, "type"); + if (blockType === "tool_use") { + const call = { id: recStr(contentBlock, "id") ?? `call_${index}`, name: recStr(contentBlock, "name") ?? "", arguments: "" }; + calls.set(index, call); + event(response, "response.output_item.added", { type: "response.output_item.added", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); + } else if (blockType === "thinking") { + announceReasoning(response, reasoning); } - text += delta.text; outputTokens++; - event(response, "response.output_text.delta", { type: "response.output_text.delta", item_id: msgId, output_index: 0, content_index: 0, delta: delta.text }); - } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") { - rsText += delta.thinking; - event(response, "response.reasoning_summary_text.delta", { type: "response.reasoning_summary_text.delta", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, delta: delta.thinking }); - } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { - const call = calls.get(item.index); - if (call) { - call.arguments += delta.partial_json; - event(response, "response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: `fc_${item.index}`, output_index: item.index, call_id: call.id, delta: delta.partial_json }); + } + const delta = recObj(item, "delta"); + if (item.type === "content_block_delta") { + const deltaType = recStr(delta, "type"); + const textDelta = recStr(delta, "text"); + const thinkingDelta = recStr(delta, "thinking"); + const partialJson = recStr(delta, "partial_json"); + if (deltaType === "text_delta" && textDelta !== undefined) { + announceMessageItem(response, msgId, messageAnnounced); + text += textDelta; capture.output++; + emitTextDelta(response, msgId, textDelta); + } else if (deltaType === "thinking_delta" && thinkingDelta !== undefined) { + emitReasoningDelta(response, reasoning, thinkingDelta); + } else if (deltaType === "input_json_delta" && partialJson !== undefined) { + const call = calls.get(index); + if (call) { + call.arguments += partialJson; + emitFunctionCallDelta(response, { itemId: `fc_${index}`, outputIndex: index, callId: call.id, delta: partialJson }); + } } } + if (item.type === "message_delta") { + if (recStr(recObj(item, "delta"), "stop_reason") === "max_tokens") truncated = true; + const deltaUsage = recObj(item, "usage"); + if (deltaUsage) usage.usage(deltaUsage); + } + if (item.type === "message_stop") sawMessageStop = true; + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - if (item.type === "message_delta") { - if (item.delta?.stop_reason === "max_tokens") truncated = true; - if (item.usage) { outputTokens = item.usage.output_tokens ?? outputTokens; lastUsage = item.usage; sawUsage = true; } + }; + try { + await drain(reader, consume); + if (!sawMessageStop) { + const message = "Upstream Anthropic stream ended before message_stop was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } - if (item.type === "message_stop") sawMessageStop = true; + emitResponsesCompleted(response, { id, model, capture, truncated, text, msgId, reasoning, calls }); } catch (error) { - // In-band upstream failures must end the stream; only parse noise is ignored. - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (!sawMessageStop) { - const message = "Upstream Anthropic stream ended before message_stop was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); - } - if (rsId) { - event(response, "response.reasoning_summary_text.done", { type: "response.reasoning_summary_text.done", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, text: rsText }); - event(response, "response.reasoning_summary_part.done", { type: "response.reasoning_summary_part.done", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: rsText } }); - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: rsId, summary: [{ type: "summary_text", text: rsText }] } }); - } - if (text) { - event(response, "response.output_text.done", { type: "response.output_text.done", item_id: msgId, output_index: 0, content_index: 0, text }); - // Codex collects turn items from output_item.done; the message must be - // finalized there or only the reasoning/tool items reach the client. - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] } }); - } - const output: any[] = []; - if (rsId) output.push({ type: "reasoning", id: rsId, summary: [{ type: "summary_text", text: rsText }] }); - if (text) output.push({ type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] }); - for (const [index, call] of calls) { - event(response, "response.function_call_arguments.done", { type: "response.function_call_arguments.done", item_id: `fc_${index}`, call_id: call.id, arguments: call.arguments }); - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" } }); - output.push({ type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" }); + emitResponsesError(response, id, model, error); } - // Codex strictly requires total_tokens on the completed response usage. - event(response, "response.completed", { type: "response.completed", response: { id, object: "response", status: truncated ? "incomplete" : "completed", model, output, usage: { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: inputTokens + outputTokens }, ...(truncated ? { incomplete_details: { reason: "max_output_tokens" } } : {}) } }); - response.write("data: [DONE]\n\n"); - } catch (error) { - event(response, "response.failed", { type: "response.failed", response: { id, object: "response", status: "failed", model, error: { code: "upstream_error", message: errorMessage(error) } } }); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/chat-passthrough.ts b/src/streaming/chat-passthrough.ts index cb18ffa..8bbe5f9 100644 --- a/src/streaming/chat-passthrough.ts +++ b/src/streaming/chat-passthrough.ts @@ -1,6 +1,9 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, reportUsage, SSE_HEADERS, startHeartbeat, withCacheTokens, type StreamUsageOptions } from "./common.js"; -import type { TokenUsage } from "../usage/types.js"; +import { + createChatEmitter, dataLine, drain, emitChatError, newUsageCapture, + reportUsage, usageCapture, withSsePipe, type StreamUsageOptions, +} from "./common.js"; +import { jsonRecord, recObj, recObjs, recStr } from "../json.js"; /** * Forward a Chat Completions SSE stream byte-for-byte. Used for the local @@ -13,33 +16,29 @@ import type { TokenUsage } from "../usage/types.js"; * not. */ export async function pipeChatPassthrough(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - const stopHeartbeat = startHeartbeat(response); - try { - let usage: TokenUsage | null = null; let inputTokens = 0; let outputTokens = 0; let lastUsage: any; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value || value === "[DONE]") return; + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, options?.protocol ?? "chat-completions", !!options); + let countedOutput = 0; + const chunk = createChatEmitter(response, model); + const consume = (line: string) => { + const value = dataLine(line); + if (!value || value === "[DONE]") return; + try { + const item = jsonRecord(JSON.parse(value)); + const delta = recStr(recObj(recObjs(item, "choices")[0], "delta"), "content"); + if (delta) countedOutput++; + const itemUsage = recObj(item, "usage"); + if (itemUsage) usage.usage(itemUsage); + } catch { /* Ignore incomplete provider events. */ } + }; try { - const item = JSON.parse(value); - const delta = item.choices?.[0]?.delta?.content; - if (typeof delta === "string" && delta) outputTokens++; - if (item.usage) { - inputTokens = item.usage.prompt_tokens ?? inputTokens; const usageOutputTokens = item.usage.completion_tokens ?? outputTokens; - lastUsage = item.usage; - usage = withCacheTokens({ provider: options?.provider ?? "", model, inputTokens, outputTokens: usageOutputTokens, totalTokens: item.usage.total_tokens ?? (inputTokens + usageOutputTokens), ...(options?.sessionId ? { sessionId: options.sessionId } : {}) }, item.usage); - } - } catch { /* Ignore incomplete provider events. */ } - }; - try { - // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. - await drain(reader, consume, (chunk) => response.write(chunk)); - } catch (error) { - response.write(`data: {"error":{"message":${JSON.stringify(errorMessage(error))},"type":"upstream_error"}}\n\n`); - } - response.end(); - reportUsage(options, usage !== null, (usage as TokenUsage | null)?.inputTokens ?? inputTokens, (usage as TokenUsage | null)?.outputTokens ?? outputTokens, lastUsage); - } finally { stopHeartbeat(); } + // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. + await drain(reader, consume, (chunkText) => response.write(chunkText)); + } catch (error) { + emitChatError(response, chunk, error); + } + response.end(); + reportUsage(options, { ...capture, output: capture.output || countedOutput }, usage.total()); + }); } diff --git a/src/streaming/chat-to-responses.ts b/src/streaming/chat-to-responses.ts index 8099e0d..72c823b 100644 --- a/src/streaming/chat-to-responses.ts +++ b/src/streaming/chat-to-responses.ts @@ -1,106 +1,94 @@ +import type { ServerResponse } from "node:http"; import { chatResponseFailure } from "../convert/index.js"; -import { cancelOnDisconnect, drain, errorMessage, event, failureMessage, REASONING_OUTPUT_INDEX, reasoningDeltaOf, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { + announceMessageItem, announceReasoning, dataLine, drain, emitFunctionCallDelta, + emitReasoningDelta, emitResponsesCompleted, emitResponsesError, emitTextDelta, event, failureMessage, + newUsageCapture, reasoningDeltaOf, reportUsage, usageCapture, withSsePipe, + UpstreamFailure, type ReasoningState, type StreamUsageOptions, +} from "./common.js"; +import { jsonRecord, recNum, recObj, recObjs, recStr } from "../json.js"; /** * Pipe a Chat Completions SSE upstream into Codex-style Responses events. Used * for non-OpenCode providers that still speak the legacy chat protocol. */ -export async function pipeChatStreamToResponses(upstream: Response, response: import("node:http").ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws. - const stopHeartbeat = startHeartbeat(response); - try { - const id = `resp_${crypto.randomUUID()}`; const msgId = `msg_${crypto.randomUUID()}`; let text = ""; let inputTokens = 0; let outputTokens = 0; let sawUsage = false; let truncated = false; let lastUsage: any; let rsId = ""; let rsText = ""; let messageAnnounced = false; const calls = new Map(); - // A chat-completions upstream must close every turn with either a - // finish_reason or [DONE]; seeing neither means the connection dropped - // mid-turn and must not read back as a normal "completed" response. - let sawFinishReason = false; let sawDone = false; - event(response, "response.created", { type: "response.created", response: { id, object: "response", status: "in_progress", model, output: [] } }); - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; - if (value === "[DONE]") { sawDone = true; return; } - try { - const item = JSON.parse(value); - const failure = failureMessage(item); - if (failure) throw new UpstreamFailure(failure); - const choice = item.choices?.[0]; const delta = choice?.delta?.content; - if (choice?.finish_reason != null) { - sawFinishReason = true; - const chatFailure = chatResponseFailure(item); - if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${choice.finish_reason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } - } - if (choice?.finish_reason === "length") truncated = true; - if (typeof delta === "string" && delta) { - // Codex keys text on the announced message item; announce it once. - if (!messageAnnounced) { - messageAnnounced = true; - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] } }); +export async function pipeChatStreamToResponses(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { + await withSsePipe(upstream, response, async (reader) => { + const id = `resp_${crypto.randomUUID()}`; const msgId = `msg_${crypto.randomUUID()}`; + const capture = newUsageCapture(); + const usage = usageCapture(capture, "chat-completions"); + let text = ""; let truncated = false; + const messageAnnounced = { done: false }; + const reasoning: ReasoningState = { id: "", text: "" }; + const calls = new Map(); + // A chat-completions upstream must close every turn with either a + // finish_reason or [DONE]; seeing neither means the connection dropped + // mid-turn and must not read back as a normal "completed" response. + let sawFinishReason = false; let sawDone = false; + event(response, "response.created", { type: "response.created", response: { id, object: "response", status: "in_progress", model, output: [] } }); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + if (value === "[DONE]") { sawDone = true; return; } + try { + const item = jsonRecord(JSON.parse(value)); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + const choice = recObjs(item, "choices")[0]; + const delta = recObj(choice, "delta"); + const finishReason = recStr(choice, "finish_reason"); + if (finishReason !== undefined) { + sawFinishReason = true; + const chatFailure = chatResponseFailure(item); + if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${finishReason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } } - text += delta; outputTokens++; event(response, "response.output_text.delta", { type: "response.output_text.delta", item_id: msgId, output_index: 0, content_index: 0, delta }); - } - const reasoningDelta = reasoningDeltaOf(item); - if (reasoningDelta) { - if (!rsId) { - rsId = `rs_${crypto.randomUUID()}`; - event(response, "response.output_item.added", { type: "response.output_item.added", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: rsId, summary: [] } }); - event(response, "response.reasoning_summary_part.added", { type: "response.reasoning_summary_part.added", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: "" } }); + if (finishReason === "length") truncated = true; + const content = recStr(delta, "content"); + if (content) { + announceMessageItem(response, msgId, messageAnnounced); + text += content; capture.output++; emitTextDelta(response, msgId, content); + } + const reasoningDelta = reasoningDeltaOf(item); + if (reasoningDelta) { + announceReasoning(response, reasoning); + emitReasoningDelta(response, reasoning, reasoningDelta); + } + for (const tool of recObjs(delta, "tool_calls")) { + const index = recNum(tool, "index") ?? 0; + const toolId = recStr(tool, "id"); + // Announce each call exactly once even when providers repeat id/name in deltas. + const call = calls.get(index) ?? { id: toolId ?? `call_${index}`, name: "", arguments: "", announced: false }; + if (toolId) call.id = toolId; + const toolFunction = recObj(tool, "function"); + const toolName = recStr(toolFunction, "name"); + if (toolName) call.name = toolName; + calls.set(index, call); + if (!call.announced) { event(response, "response.output_item.added", { type: "response.output_item.added", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); call.announced = true; } + const partialJson = recStr(toolFunction, "arguments"); + if (partialJson) { + call.arguments += partialJson; + emitFunctionCallDelta(response, { itemId: `fc_${index}`, outputIndex: index, callId: call.id, delta: partialJson }); + } } - rsText += reasoningDelta; - event(response, "response.reasoning_summary_text.delta", { type: "response.reasoning_summary_text.delta", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, delta: reasoningDelta }); + const itemUsage = recObj(item, "usage"); + if (itemUsage) usage.usage(itemUsage); + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - for (const tool of choice?.delta?.tool_calls ?? []) { - const index = tool.index ?? 0; - // Announce each call exactly once even when providers repeat id/name in deltas. - const call = calls.get(index) ?? { id: tool.id ?? `call_${index}`, name: "", arguments: "", announced: false }; - if (tool.id) call.id = tool.id; - if (tool.function?.name) call.name = tool.function.name; - calls.set(index, call); - if (!call.announced) { event(response, "response.output_item.added", { type: "response.output_item.added", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: "", status: "in_progress" } }); call.announced = true; } - if (tool.function?.arguments) { call.arguments += tool.function.arguments; event(response, "response.function_call_arguments.delta", { type: "response.function_call_arguments.delta", item_id: `fc_${index}`, output_index: index, call_id: call.id, delta: tool.function.arguments }); } + }; + try { + await drain(reader, consume); + if (!sawFinishReason && !sawDone) { + const message = "Upstream chat completions stream ended before a finish_reason or [DONE] was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } - if (item.usage) { inputTokens = item.usage.prompt_tokens ?? inputTokens; outputTokens = item.usage.completion_tokens ?? outputTokens; lastUsage = item.usage; sawUsage = true; } + emitResponsesCompleted(response, { id, model, capture, truncated, text, msgId, reasoning, calls }); } catch (error) { - // In-band upstream failures must end the stream; only parse noise is ignored. - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (!sawFinishReason && !sawDone) { - const message = "Upstream chat completions stream ended before a finish_reason or [DONE] was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); - } - if (rsId) { - event(response, "response.reasoning_summary_text.done", { type: "response.reasoning_summary_text.done", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, text: rsText }); - event(response, "response.reasoning_summary_part.done", { type: "response.reasoning_summary_part.done", item_id: rsId, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: rsText } }); - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: rsId, summary: [{ type: "summary_text", text: rsText }] } }); - } - if (text) { - event(response, "response.output_text.done", { type: "response.output_text.done", item_id: msgId, output_index: 0, content_index: 0, text }); - // Codex collects turn items from output_item.done; the message must be - // finalized there or only the reasoning item reaches the client. - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] } }); - } - const output: any[] = []; - if (rsId) output.push({ type: "reasoning", id: rsId, summary: [{ type: "summary_text", text: rsText }] }); - if (text) output.push({ type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] }); - for (const [index, call] of calls) { - event(response, "response.function_call_arguments.done", { type: "response.function_call_arguments.done", item_id: `fc_${index}`, call_id: call.id, arguments: call.arguments }); - event(response, "response.output_item.done", { type: "response.output_item.done", output_index: index, item: { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" } }); - output.push({ type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" }); + emitResponsesError(response, id, model, error); } - // Codex strictly requires total_tokens on the completed response usage. - event(response, "response.completed", { type: "response.completed", response: { id, object: "response", status: truncated ? "incomplete" : "completed", model, output, usage: { input_tokens: inputTokens, output_tokens: outputTokens, total_tokens: inputTokens + outputTokens }, ...(truncated ? { incomplete_details: { reason: "max_output_tokens" } } : {}) } }); - response.write("data: [DONE]\n\n"); - } catch (error) { - event(response, "response.failed", { type: "response.failed", response: { id, object: "response", status: "failed", model, error: { code: "upstream_error", message: errorMessage(error) } } }); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/common.ts b/src/streaming/common.ts index decbf17..4d40bb4 100644 --- a/src/streaming/common.ts +++ b/src/streaming/common.ts @@ -1,12 +1,14 @@ import type { ServerResponse } from "node:http"; import type { TokenUsage } from "../usage/types.js"; import type { ProviderProtocol } from "../providers/types.js"; +import type { JsonRecord } from "../json.js"; +import { recCount, recObj, recObjs, recStr } from "../json.js"; import { mapAnthropicUsage, mapChatUsage, mapResponsesUsage } from "../providers/usage/index.js"; export const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }; /** How often to emit an SSE comment while the upstream is quiet. */ -export const HEARTBEAT_MS = 15_000; +const HEARTBEAT_MS = 15_000; /** Dedicated high output_index for synthesized reasoning items so they never collide with text (0) or tool call indexes. */ export const REASONING_OUTPUT_INDEX = 1000; @@ -25,20 +27,51 @@ export interface StreamUsageOptions { * SSE comment lines during long upstream silences. Clients and intermediate * hops ignore them, but they reset idle timeouts so the turn is not dropped. */ -export function startHeartbeat(response: ServerResponse) { +function startHeartbeat(response: ServerResponse) { const timer = setInterval(() => response.write(": ping\n\n"), HEARTBEAT_MS); return () => clearInterval(timer); } +/** Stop reading from the upstream as soon as the local client disconnects. */ +function cancelOnDisconnect(response: ServerResponse, reader: ReadableStreamDefaultReader) { + response.on("close", () => { void reader.cancel().catch(() => {}); }); +} + +/** + * Shared preamble/teardown for every stream pipe: take the upstream reader, + * arm disconnect cancellation, send SSE headers, and run `run` with the SSE + * heartbeat active (cleared even if setup or `run` throws). + */ +export async function withSsePipe(upstream: Response, response: ServerResponse, run: (reader: ReadableStreamDefaultReader) => Promise): Promise { + const reader = upstream.body?.getReader(); + if (!reader) throw new Error("Upstream returned no stream"); + cancelOnDisconnect(response, reader); + response.writeHead(200, SSE_HEADERS); + // try/finally guarantees the interval is cleared even if a write to the + // local client throws before the main try block is entered. + const stopHeartbeat = startHeartbeat(response); + try { + await run(reader); + } finally { + stopHeartbeat(); + } +} + +/** Payload of an SSE `data:` line, or undefined for non-data/blank lines. */ +export function dataLine(line: string): string | undefined { + if (!line.startsWith("data:")) return undefined; + return line.slice(5).trim() || undefined; +} + /** A failure carried inside otherwise valid SSE data; must end the stream. */ export class UpstreamFailure extends Error {} /** Message of an in-band upstream error payload, if the parsed event carries one. */ -export function failureMessage(item: any): string | undefined { - if (item.type === "response.failed") return item.response?.error?.message ?? item.response?.error ?? "Upstream response failed"; - if (item.type === "error") return item.error?.message ?? (typeof item.message === "string" ? item.message : undefined) ?? "Upstream stream failed"; - if (typeof item.error?.message === "string") return item.error.message; - return undefined; +export function failureMessage(item: JsonRecord): string | undefined { + const response = recObj(item, "response"); + if (item.type === "response.failed") return recStr(recObj(response, "error"), "message") ?? recStr(response, "error") ?? "Upstream response failed"; + if (item.type === "error") return recStr(recObj(item, "error"), "message") ?? recStr(item, "message") ?? "Upstream stream failed"; + return recStr(recObj(item, "error"), "message"); } export function event(response: ServerResponse, type: string, data: unknown) { @@ -49,9 +82,23 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "Upstream stream failed"; } -/** Stop reading from the upstream as soon as the local client disconnects. */ -export function cancelOnDisconnect(response: ServerResponse, reader: ReadableStreamDefaultReader) { - response.on("close", () => { void reader.cancel().catch(() => {}); }); +/** + * Terminal error handling per client-facing protocol grammar: + * Anthropic clients expect a final `error` event, Responses clients a + * `response.failed`, and Chat Completions clients — whose chunk grammar has + * no error event pair — get the message inlined as content before [DONE]. + */ +export function emitAnthropicError(response: ServerResponse, error: unknown) { + event(response, "error", { type: "error", error: { type: "api_error", message: errorMessage(error) } }); +} + +export function emitResponsesError(response: ServerResponse, id: string, model: string, error: unknown) { + event(response, "response.failed", { type: "response.failed", response: { id, object: "response", status: "failed", model, error: { code: "upstream_error", message: errorMessage(error) } } }); +} + +export function emitChatError(response: ServerResponse, chunk: (delta: Record, finishReason?: string | null) => void, error: unknown) { + chunk({ content: `\n[error: ${errorMessage(error)}]` }, "stop"); + response.write("data: [DONE]\n\n"); } /** Read the upstream SSE body line by line; `onChunk` sees each decoded chunk (passthrough). */ @@ -84,12 +131,11 @@ export async function drain(reader: ReadableStreamDefaultReader, con * and keeps the first hit; each mapper's own field list is the single source * of truth (providers/usage/*.ts), not duplicated here. */ -export function cacheTokensOf(usage: any): { cached?: number; reasoning?: number } { +export function cacheTokensOf(usage: JsonRecord | undefined): { cached?: number; reasoning?: number } { const chat = mapChatUsage(usage, {}); const responses = mapResponsesUsage(usage, {}); const anthropic = mapAnthropicUsage(usage, {}); - const cached = chat?.cachedInputTokens ?? responses?.cachedInputTokens ?? anthropic?.cachedInputTokens - ?? (usage?.cached_tokens === undefined || usage?.cached_tokens === null ? undefined : Number(usage.cached_tokens)); + const cached = chat?.cachedInputTokens ?? responses?.cachedInputTokens ?? anthropic?.cachedInputTokens ?? recCount(usage, "cached_tokens"); const reasoning = chat?.reasoningTokens ?? responses?.reasoningTokens; return { ...(cached === undefined ? {} : { cached }), @@ -98,33 +144,229 @@ export function cacheTokensOf(usage: any): { cached?: number; reasoning?: number } /** Attach captured cache/reasoning tokens to a usage record when present. */ -export function withCacheTokens(usage: TokenUsage, source: any): TokenUsage { +export function withCacheTokens(usage: TokenUsage, source: JsonRecord | undefined): TokenUsage { const { cached, reasoning } = cacheTokensOf(source); if (cached !== undefined) usage.cachedInputTokens = cached; if (reasoning !== undefined) usage.reasoningTokens = reasoning; return usage; } +/** Per-request token counters every pipe tracks; the single store pipes read when emitting and reporting usage. */ +export interface UsageCapture { + input: number; + output: number; + /** The most recent raw upstream usage payload, for protocol-shape-dependent emission (e.g. Anthropic cache_read). */ + raw?: JsonRecord; + sawUsage: boolean; + /** Cache/reasoning fields mapped from the latest raw usage via the provider field mappers. */ + cached?: number; + reasoning?: number; + cacheWrite?: number; +} + +export function newUsageCapture(): UsageCapture { + return { input: 0, output: 0, sawUsage: false }; +} + +function mapRawUsage(raw: JsonRecord | undefined, protocol: ProviderProtocol): TokenUsage | null { + switch (protocol) { + // Bare-usage field lists live in providers/usage/*; the core never parses + // provider payloads itself (CLAUDE.md architecture rule). + case "anthropic": return mapAnthropicUsage(raw, {}); + case "responses": return mapResponsesUsage(raw, {}); + case "chat-completions": return mapChatUsage(raw, {}); + } +} + +/** + * Bind usage capture to the upstream protocol so token fields are read via + * the same provider field mappers as the non-streaming path. `usage()` merges + * a raw usage object into the capture; `total()` prefers the upstream's own + * total when it carries one. `enabled=false` (a pipe called with no options) + * keeps raw payloads as estimation hints without claiming real usage, which + * `reportUsage`'s fallback then reports. + */ +export function usageCapture(capture: UsageCapture, protocol: ProviderProtocol, enabled = true): { + usage(raw: JsonRecord | undefined): void; + total(): number; +} { + return { + usage(raw) { + const mapped = mapRawUsage(raw, protocol); + if (!mapped) return; + capture.raw = raw; + if (!enabled) return; + capture.sawUsage = true; + if (mapped.inputTokens) capture.input = mapped.inputTokens; + if (mapped.outputTokens) capture.output = mapped.outputTokens; + if (mapped.cachedInputTokens !== undefined) capture.cached = mapped.cachedInputTokens; + if (mapped.reasoningTokens !== undefined) capture.reasoning = mapped.reasoningTokens; + if (mapped.cacheWriteTokens !== undefined) capture.cacheWrite = mapped.cacheWriteTokens; + }, + total() { + return recCount(capture.raw, "total_tokens") ?? capture.input + capture.output; + }, + }; +} + function estimatedUsage(provider: string, model: string, inputTokens: number, outputTokens: number, sessionId?: string): TokenUsage { return { provider, model, inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, estimated: true, ...(sessionId ? { sessionId } : {}) }; } /** Reasoning text shared by Responses (`reasoning_summary_text`/`reasoning_text`) and chat (`reasoning_content`/`reasoning`) deltas. */ -export function reasoningDeltaOf(item: any): string | undefined { - const delta = item.choices?.[0]?.delta; - const chat = delta?.reasoning_content ?? delta?.reasoning; - if (typeof chat === "string" && chat) return chat; - if (item.type === "response.reasoning_summary_text.delta" || item.type === "response.reasoning_text.delta") { - const value = item.delta; - if (typeof value === "string" && value) return value; +export function reasoningDeltaOf(item: JsonRecord): string | undefined { + const delta = recObj(recObjs(item, "choices")[0], "delta"); + const chat = recStr(delta, "reasoning_content") ?? recStr(delta, "reasoning"); + if (chat) return chat; + const type = recStr(item, "type"); + if (type === "response.reasoning_summary_text.delta" || type === "response.reasoning_text.delta") { + return recStr(item, "delta") || undefined; } return undefined; } -/** Final usage reporting; called by every pipe. */ -export function reportUsage(options: StreamUsageOptions | undefined, sawUsage: boolean, inputTokens: number, outputTokens: number, lastUsage?: any) { +/** Final usage reporting; called by every pipe. Captured fields ride on the + * usage record itself; a stream that never carried a usage payload reports an + * estimate from the per-delta counters instead. */ +export function reportUsage(options: StreamUsageOptions | undefined, capture: UsageCapture, totalOverride?: number) { if (!options?.onUsage) return; - options.onUsage(sawUsage - ? withCacheTokens({ provider: options.provider, model: options.model, inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, ...(options.sessionId ? { sessionId: options.sessionId } : {}) }, lastUsage) - : estimatedUsage(options.provider, options.model, inputTokens, outputTokens, options.sessionId)); + if (!capture.sawUsage) { + options.onUsage(estimatedUsage(options.provider, options.model, capture.input, capture.output, options.sessionId)); + return; + } + const usage: TokenUsage = { provider: options.provider, model: options.model, inputTokens: capture.input, outputTokens: capture.output, totalTokens: totalOverride ?? capture.input + capture.output, ...(options.sessionId ? { sessionId: options.sessionId } : {}) }; + if (capture.cached !== undefined) usage.cachedInputTokens = capture.cached; + if (capture.reasoning !== undefined) usage.reasoningTokens = capture.reasoning; + if (capture.cacheWrite !== undefined) usage.cacheWriteTokens = capture.cacheWrite; + options.onUsage(usage); +} + +/* ---------- Responses-protocol (Codex-facing) emitters ---------- */ + +/** Per-pipe state for the synthesized reasoning item (`rs_*` id plus accumulated text). */ +export interface ReasoningState { + id: string; + text: string; +} + +/** Announce the reasoning item once, when the first reasoning delta arrives. */ +export function announceReasoning(response: ServerResponse, state: ReasoningState) { + if (state.id) return; + state.id = `rs_${crypto.randomUUID()}`; + event(response, "response.output_item.added", { type: "response.output_item.added", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: state.id, summary: [] } }); + event(response, "response.reasoning_summary_part.added", { type: "response.reasoning_summary_part.added", item_id: state.id, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: "" } }); +} + +export function emitReasoningDelta(response: ServerResponse, state: ReasoningState, delta: string) { + state.text += delta; + event(response, "response.reasoning_summary_text.delta", { type: "response.reasoning_summary_text.delta", item_id: state.id, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, delta }); +} + +/** Close out the reasoning item; both Responses-facing pipes emit the identical terminal triplet. */ +export function emitReasoningDone(response: ServerResponse, state: ReasoningState) { + if (!state.id) return; + event(response, "response.reasoning_summary_text.done", { type: "response.reasoning_summary_text.done", item_id: state.id, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, text: state.text }); + event(response, "response.reasoning_summary_part.done", { type: "response.reasoning_summary_part.done", item_id: state.id, output_index: REASONING_OUTPUT_INDEX, summary_index: 0, part: { type: "summary_text", text: state.text } }); + event(response, "response.output_item.done", { type: "response.output_item.done", output_index: REASONING_OUTPUT_INDEX, item: { type: "reasoning", id: state.id, summary: [{ type: "summary_text", text: state.text }] } }); +} + +/** Announce the assistant message item once, when the first text delta arrives. */ +export function announceMessageItem(response: ServerResponse, msgId: string, announced: { done: boolean }) { + // Codex keys text on the announced message item; announce it once. + if (announced.done) return; + announced.done = true; + event(response, "response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] } }); +} + +export function emitTextDelta(response: ServerResponse, msgId: string, delta: string) { + event(response, "response.output_text.delta", { type: "response.output_text.delta", item_id: msgId, output_index: 0, content_index: 0, delta }); +} + +export function emitFunctionCallDelta(response: ServerResponse, args: { itemId: string; outputIndex?: number; callId: string; delta: string }) { + event(response, "response.function_call_arguments.delta", { + type: "response.function_call_arguments.delta", + item_id: args.itemId, + ...(args.outputIndex === undefined ? {} : { output_index: args.outputIndex }), + call_id: args.callId, + delta: args.delta, + }); +} + +export function reasoningItem(state: ReasoningState) { + return { type: "reasoning", id: state.id, summary: [{ type: "summary_text", text: state.text }] }; +} + +export function messageItem(msgId: string, text: string) { + return { type: "message", id: msgId, role: "assistant", status: "completed", content: [{ type: "output_text", text }] }; +} + +export function functionCallItem(call: { id: string; name: string; arguments: string }, index: number | string) { + return { type: "function_call", id: `fc_${index}`, call_id: call.id, name: call.name, arguments: call.arguments, status: "completed" }; +} + +/** + * Finalize the turn for Responses-facing clients: text/reasoning done events, + * per-call argument-done + item-done events, then the completed response — + * Codex strictly requires total_tokens there. Chat-style `calls` carry no + * `arguments`, so the caller supplies them via `argsOf`. + */ +export function emitResponsesCompleted( + response: ServerResponse, + args: { + id: string; + model: string; + capture: UsageCapture; + truncated: boolean; + text?: string; + msgId?: string; + reasoning?: ReasoningState; + calls: Iterable<[number | string, { id: string; name: string; arguments?: string }]>; + argsOf?(index: number | string): string; + }, +) { + const { id, model, capture, truncated } = args; + if (args.reasoning) emitReasoningDone(response, args.reasoning); + if (args.text) { + const msgId = args.msgId ?? ""; + event(response, "response.output_text.done", { type: "response.output_text.done", item_id: msgId, output_index: 0, content_index: 0, text: args.text }); + // Codex collects turn items from output_item.done; the message must be + // finalized there or only the reasoning/tool items reach the client. + event(response, "response.output_item.done", { type: "response.output_item.done", output_index: 0, item: messageItem(msgId, args.text) }); + } + const output: unknown[] = []; + if (args.reasoning?.id) output.push(reasoningItem(args.reasoning)); + if (args.text) output.push(messageItem(args.msgId ?? "", args.text)); + for (const [index, call] of args.calls) { + const callArgs = call.arguments ?? args.argsOf?.(index) ?? ""; + event(response, "response.function_call_arguments.done", { type: "response.function_call_arguments.done", item_id: `fc_${index}`, call_id: call.id, arguments: callArgs }); + event(response, "response.output_item.done", { type: "response.output_item.done", output_index: index, item: functionCallItem({ ...call, arguments: callArgs }, index) }); + output.push(functionCallItem({ ...call, arguments: callArgs }, index)); + } + event(response, "response.completed", { type: "response.completed", response: { id, object: "response", status: truncated ? "incomplete" : "completed", model, output, usage: { input_tokens: capture.input, output_tokens: capture.output, total_tokens: capture.input + capture.output }, ...(truncated ? { incomplete_details: { reason: "max_output_tokens" } } : {}) } }); + response.write("data: [DONE]\n\n"); +} + +/* ---------- Chat-Completions-protocol emitters ---------- */ + +export interface ChatToolCallState { + id: string; + name: string; + announced: boolean; +} + +/** `data:` writer for `chat.completion.chunk` events, shared by both Chat-facing pipes. */ +export function createChatEmitter(response: ServerResponse, model: string) { + const id = `chatcmpl_${crypto.randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + return function chunk(delta: Record, finishReason: string | null = null) { + response.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`); + }; +} + +/** Emit one streamed tool-call delta, announcing id/name/type on the first chunk for that call. */ +export function emitChatToolDelta(chunk: (delta: Record, finishReason?: string | null) => void, call: ChatToolCallState, index: number, args: string) { + const fn: Record = { arguments: args }; + const delta: Record = { index, function: fn }; + if (!call.announced) { call.announced = true; delta.id = call.id; delta.type = "function"; fn.name = call.name; } + chunk({ tool_calls: [delta] }); } diff --git a/src/streaming/responses-passthrough.ts b/src/streaming/responses-passthrough.ts index daca7be..14e4394 100644 --- a/src/streaming/responses-passthrough.ts +++ b/src/streaming/responses-passthrough.ts @@ -1,6 +1,9 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, event, reportUsage, SSE_HEADERS, startHeartbeat, withCacheTokens, type StreamUsageOptions } from "./common.js"; -import type { TokenUsage } from "../usage/types.js"; +import { + dataLine, drain, emitResponsesError, newUsageCapture, reportUsage, + usageCapture, withSsePipe, type StreamUsageOptions, +} from "./common.js"; +import { jsonRecord, recObj, recStr } from "../json.js"; /** * Forward a Responses-protocol SSE stream byte-for-byte. Usage is captured from @@ -8,35 +11,27 @@ import type { TokenUsage } from "../usage/types.js"; * the same events the upstream emitted. */ export async function pipeResponsesPassthrough(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws before the main try block is entered. - const stopHeartbeat = startHeartbeat(response); - try { - let usage: TokenUsage | null = null; let outputTokens = 0; let lastUsage: any; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value || value === "[DONE]") return; + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, options?.protocol ?? "responses", !!options); + let outputTokens = 0; + const consume = (line: string) => { + const value = dataLine(line); + if (!value || value === "[DONE]") return; + try { + const item = jsonRecord(JSON.parse(value)); + if (item.type === "response.output_text.delta" && recStr(item, "delta") !== undefined) outputTokens++; + const completedUsage = recObj(recObj(item, "response"), "usage"); + if (completedUsage) usage.usage(completedUsage); + } catch { /* Ignore incomplete provider events. */ } + }; try { - const item = JSON.parse(value); - if (item.type === "response.output_text.delta" && typeof item.delta === "string") outputTokens++; - if (item.response?.usage && options) { - const inputTokens = item.response.usage.input_tokens ?? 0; const usageOutputTokens = item.response.usage.output_tokens ?? 0; - lastUsage = item.response.usage; - usage = withCacheTokens({ provider: options.provider, model, inputTokens, outputTokens: usageOutputTokens, totalTokens: item.response.usage.total_tokens ?? (inputTokens + usageOutputTokens), ...(options.sessionId ? { sessionId: options.sessionId } : {}) }, item.response.usage); - } - } catch { /* Ignore incomplete provider events. */ } - }; - try { - // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. - await drain(reader, consume, (chunk) => response.write(chunk)); - } catch (error) { - const id = `resp_${crypto.randomUUID()}`; - event(response, "response.failed", { type: "response.failed", response: { id, object: "response", status: "failed", model, error: { code: "upstream_error", message: errorMessage(error) } } }); - } - response.end(); - reportUsage(options, usage !== null, (usage as TokenUsage | null)?.inputTokens ?? 0, (usage as TokenUsage | null)?.outputTokens ?? outputTokens, lastUsage); - } finally { stopHeartbeat(); } + // Forward the decoded chunks verbatim; SSE is text so this is byte-faithful. + await drain(reader, consume, (chunk) => response.write(chunk)); + } catch (error) { + emitResponsesError(response, `resp_${crypto.randomUUID()}`, model, error); + } + response.end(); + reportUsage(options, { ...capture, output: capture.output || outputTokens }, usage.total()); + }); } diff --git a/src/streaming/responses-to-chat.ts b/src/streaming/responses-to-chat.ts index 016356e..0f9c50e 100644 --- a/src/streaming/responses-to-chat.ts +++ b/src/streaming/responses-to-chat.ts @@ -1,5 +1,10 @@ import type { ServerResponse } from "node:http"; -import { cancelOnDisconnect, drain, errorMessage, failureMessage, reasoningDeltaOf, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { jsonRecord, recObj, recStr } from "../json.js"; +import { + createChatEmitter, dataLine, drain, emitChatError, emitChatToolDelta, failureMessage, + newUsageCapture, reasoningDeltaOf, reportUsage, usageCapture, withSsePipe, UpstreamFailure, + type ChatToolCallState, type StreamUsageOptions, +} from "./common.js"; /** * Pipe a native Responses SSE stream into OpenAI-style `chat.completion.chunk` @@ -11,62 +16,59 @@ import { cancelOnDisconnect, drain, errorMessage, failureMessage, reasoningDelta * upstream shape): this pipe is only ever selected for `protocol === "responses"`. */ export async function pipeResponsesStreamToChat(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - const stopHeartbeat = startHeartbeat(response); - try { - const id = `chatcmpl_${crypto.randomUUID()}`; const created = Math.floor(Date.now() / 1000); - let inputTokens = 0; let outputTokens = 0; let sawUsage = false; let truncated = false; let lastUsage: any; let sawCompleted = false; - const calls = new Map(); - const chunk = (delta: Record, finishReason: string | null = null) => { - response.write(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`); - }; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; const value = line.slice(5).trim(); if (!value) return; - try { - const item = JSON.parse(value); - const failure = failureMessage(item); - if (failure) throw new UpstreamFailure(failure); - if (item.type === "response.output_text.delta" && typeof item.delta === "string") { outputTokens++; chunk({ content: item.delta }); } - const reasoning = reasoningDeltaOf(item); - if (typeof reasoning === "string") chunk({ reasoning_content: reasoning }); - if (item.type === "response.output_item.added" && item.item?.type === "function_call") { - const key = item.item.call_id ?? item.item.id ?? `call_${calls.size}`; - calls.set(key, { index: calls.size, name: item.item.name ?? "", announced: false }); - } - if (item.type === "response.function_call_arguments.delta" && typeof item.delta === "string") { - const key = item.call_id ?? item.item_id ?? ""; - const call = calls.get(key) ?? { index: calls.size, name: item.name ?? "", announced: false }; - calls.set(key, call); - const toolCall: Record = { index: call.index, function: { arguments: item.delta } }; - if (!call.announced) { call.announced = true; toolCall.id = key; toolCall.type = "function"; (toolCall.function as any).name = call.name; } - chunk({ tool_calls: [toolCall] }); + await withSsePipe(upstream, response, async (reader) => { + const capture = newUsageCapture(); + const usage = usageCapture(capture, "responses"); + let truncated = false; let sawCompleted = false; + const calls = new Map(); + const chunk = createChatEmitter(response, model); + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + try { + const item = jsonRecord(JSON.parse(value)); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + const delta = recStr(item, "delta"); + if (item.type === "response.output_text.delta" && delta !== undefined) { capture.output++; chunk({ content: delta }); } + const reasoning = reasoningDeltaOf(item); + if (reasoning) chunk({ reasoning_content: reasoning }); + const addedItem = recObj(item, "item"); + if (item.type === "response.output_item.added" && addedItem?.type === "function_call") { + const key = recStr(addedItem, "call_id") ?? recStr(addedItem, "id") ?? `call_${calls.size}`; + calls.set(key, { index: calls.size, id: key, name: recStr(addedItem, "name") ?? "", announced: false }); + } + if (item.type === "response.function_call_arguments.delta" && delta !== undefined) { + const key = recStr(item, "call_id") ?? recStr(item, "item_id") ?? ""; + const call = calls.get(key) ?? { index: calls.size, id: key, name: recStr(item, "name") ?? "", announced: false }; + calls.set(key, call); + emitChatToolDelta(chunk, call, call.index, delta); + } + if (item.type === "response.completed") { + sawCompleted = true; + const completed = recObj(item, "response"); + if (completed?.status === "incomplete") truncated = true; + const completedUsage = recObj(completed, "usage"); + if (completedUsage) usage.usage(completedUsage); + } + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - if (item.type === "response.completed") { - sawCompleted = true; - if (item.response?.status === "incomplete") truncated = true; - if (item.response?.usage) { inputTokens = item.response.usage.input_tokens ?? inputTokens; outputTokens = item.response.usage.output_tokens ?? outputTokens; lastUsage = item.response.usage; sawUsage = true; } + }; + try { + await drain(reader, consume); + if (!sawCompleted) { + const message = "Upstream Responses stream ended before response.completed was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } + chunk({}, calls.size ? "tool_calls" : truncated ? "length" : "stop"); + response.write("data: [DONE]\n\n"); } catch (error) { - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (!sawCompleted) { - const message = "Upstream Responses stream ended before response.completed was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); + emitChatError(response, chunk, error); } - chunk({}, calls.size ? "tool_calls" : truncated ? "length" : "stop"); - response.write("data: [DONE]\n\n"); - } catch (error) { - chunk({ content: `\n[error: ${errorMessage(error)}]` }, "stop"); - response.write("data: [DONE]\n\n"); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/streaming/to-anthropic.ts b/src/streaming/to-anthropic.ts index b0b7816..b6547a4 100644 --- a/src/streaming/to-anthropic.ts +++ b/src/streaming/to-anthropic.ts @@ -1,6 +1,10 @@ import type { ServerResponse } from "node:http"; import { chatResponseFailure } from "../convert/index.js"; -import { cacheTokensOf, cancelOnDisconnect, drain, errorMessage, event, failureMessage, reasoningDeltaOf, reportUsage, SSE_HEADERS, startHeartbeat, UpstreamFailure, type StreamUsageOptions } from "./common.js"; +import { jsonRecord, recNum, recObj, recObjs, recStr } from "../json.js"; +import { + cacheTokensOf, dataLine, drain, emitAnthropicError, event, failureMessage, newUsageCapture, reasoningDeltaOf, + reportUsage, usageCapture, withSsePipe, UpstreamFailure, type StreamUsageOptions, +} from "./common.js"; /** * Translate a Responses or Chat Completions SSE stream into Anthropic message @@ -8,120 +12,129 @@ import { cacheTokensOf, cancelOnDisconnect, drain, errorMessage, event, failureM * provider actually speaks. */ export async function pipeResponsesStream(upstream: Response, response: ServerResponse, model: string, options?: StreamUsageOptions) { - const reader = upstream.body?.getReader(); - if (!reader) throw new Error("Upstream returned no stream"); - cancelOnDisconnect(response, reader); - response.writeHead(200, SSE_HEADERS); - // try/finally guarantees the interval is cleared even if a write to the - // local client throws before the main try block is entered. - const stopHeartbeat = startHeartbeat(response); - try { - const id = `msg_${crypto.randomUUID()}`; - event(response, "message_start", { type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }); - let outputTokens = 0; let inputTokens = 0; let sawUsage = false; let blockIndex = 0; let blockStarted = false; let blockType: "text" | "tool_use" | "thinking" | undefined; let toolStop = false; let truncated = false; let lastUsage: any; - // A chat-completions-shaped upstream (unlike a native Responses one) must - // close every turn with either a finish_reason or [DONE]; seeing neither - // means the connection dropped mid-turn and must not read back as end_turn. - let sawChatShape = false; let sawFinishReason = false; let sawDone = false; - // Parallel tool calls arrive interleaved and keyed by chat `index` or - // Responses `item_id`; each key gets its own Anthropic content block. - const calls = new Map(); - let activeTool: string | number | null = null; - const stopBlock = () => { if (blockStarted) { event(response, "content_block_stop", { type: "content_block_stop", index: blockIndex }); blockStarted = false; blockType = undefined; blockIndex++; activeTool = null; } }; - const startText = () => { - // Text after a tool call or thinking must not append to that block. - if (!blockStarted || blockType !== "text") { - stopBlock(); - event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "text", text: "" } }); - blockStarted = true; blockType = "text"; - } - }; - const startThinking = () => { - if (!blockStarted || blockType !== "thinking") { - stopBlock(); - event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "thinking", thinking: "" } }); - blockStarted = true; blockType = "thinking"; - } - }; - const startTool = (id: string, name: string) => { stopBlock(); event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", id, name, input: {} } }); blockStarted = true; blockType = "tool_use"; toolStop = true; }; - /** Switch to the block of another tool call when the stream jumps between them. */ - const openTool = (key: string | number, id: string, name: string) => { - if (activeTool === key && blockStarted && blockType === "tool_use") return; - startTool(id, name); - activeTool = key; - }; - const consume = (line: string) => { - if (!line.startsWith("data:")) return; - const value = line.slice(5).trim(); if (!value) return; - if (value === "[DONE]") { sawDone = true; return; } - try { - const item = JSON.parse(value); - const failure = failureMessage(item); - if (failure) throw new UpstreamFailure(failure); - const choice = item.choices?.[0]; - if (Array.isArray(item.choices)) { - sawChatShape = true; - if (choice?.finish_reason != null) { - sawFinishReason = true; - const chatFailure = chatResponseFailure(item); - if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${choice.finish_reason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } - } + await withSsePipe(upstream, response, async (reader) => { + const id = `msg_${crypto.randomUUID()}`; + event(response, "message_start", { type: "message_start", message: { id, type: "message", role: "assistant", model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }); + const capture = newUsageCapture(); + // This pipe serves whichever non-Anthropic upstream protocol is actually + // configured, and the payload itself reveals the shape: usage arriving on + // `item.usage` is chat-shaped, on `item.response.usage` Responses-shaped. + const chat = usageCapture(capture, "chat-completions"); + const responses = usageCapture(capture, "responses"); + let blockIndex = 0; let blockStarted = false; let blockType: "text" | "tool_use" | "thinking" | undefined; let toolStop = false; let truncated = false; + // A chat-completions-shaped upstream (unlike a native Responses one) must + // close every turn with either a finish_reason or [DONE]; seeing neither + // means the connection dropped mid-turn and must not read back as end_turn. + let sawChatShape = false; let sawFinishReason = false; let sawDone = false; + // Parallel tool calls arrive interleaved and keyed by chat `index` or + // Responses `item_id`; each key gets its own Anthropic content block. + const calls = new Map(); + let activeTool: string | number | null = null; + const stopBlock = () => { if (blockStarted) { event(response, "content_block_stop", { type: "content_block_stop", index: blockIndex }); blockStarted = false; blockType = undefined; blockIndex++; activeTool = null; } }; + const startText = () => { + // Text after a tool call or thinking must not append to that block. + if (!blockStarted || blockType !== "text") { + stopBlock(); + event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "text", text: "" } }); + blockStarted = true; blockType = "text"; } - if (choice?.finish_reason === "length") truncated = true; - const reasoning = reasoningDeltaOf(item); - if (typeof reasoning === "string") { startThinking(); event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: reasoning } }); } - const text = item.type === "response.output_text.delta" - ? item.delta - : choice?.delta?.content; - if (typeof text === "string" && text) { startText(); outputTokens++; event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } }); } - const itemTool = item.type === "response.output_item.added" && item.item?.type === "function_call" ? item.item : undefined; - const responseArgs = item.type === "response.function_call_arguments.delta" ? item.delta : undefined; - for (const tool of choice?.delta?.tool_calls ?? []) { - const key = tool.index ?? 0; - const call = calls.get(key) ?? { id: tool.id ?? `call_${key}`, name: "" }; - if (tool.id) call.id = tool.id; - if (tool.function?.name) call.name = tool.function.name; - calls.set(key, call); - openTool(key, call.id, call.name); - if (tool.function?.arguments) event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: tool.function.arguments } }); + }; + const startThinking = () => { + if (!blockStarted || blockType !== "thinking") { + stopBlock(); + event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "thinking", thinking: "" } }); + blockStarted = true; blockType = "thinking"; } - if (itemTool) { - const key = itemTool.call_id ?? itemTool.id ?? `call_${blockIndex}`; - calls.set(key, { id: key, name: itemTool.name ?? "" }); - openTool(key, key, itemTool.name ?? ""); + }; + const startTool = (id: string, name: string) => { stopBlock(); event(response, "content_block_start", { type: "content_block_start", index: blockIndex, content_block: { type: "tool_use", id, name, input: {} } }); blockStarted = true; blockType = "tool_use"; toolStop = true; }; + /** Switch to the block of another tool call when the stream jumps between them. */ + const openTool = (key: string | number, id: string, name: string) => { + if (activeTool === key && blockStarted && blockType === "tool_use") return; + startTool(id, name); + activeTool = key; + }; + const consume = (line: string) => { + const value = dataLine(line); + if (!value) return; + if (value === "[DONE]") { sawDone = true; return; } + try { + const item = jsonRecord(JSON.parse(value)); + const failure = failureMessage(item); + if (failure) throw new UpstreamFailure(failure); + const choice = recObjs(item, "choices")[0]; + const delta = recObj(choice, "delta"); + const finishReason = recStr(choice, "finish_reason"); + if (Array.isArray(item.choices)) { + sawChatShape = true; + if (finishReason !== undefined) { + sawFinishReason = true; + const chatFailure = chatResponseFailure(item); + if (chatFailure) { options?.onDiagnostic?.(`chat completions finish_reason=${finishReason}: ${chatFailure}`); throw new UpstreamFailure(chatFailure); } + } + } + if (finishReason === "length") truncated = true; + const reasoning = reasoningDeltaOf(item); + if (reasoning) { startThinking(); event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "thinking_delta", thinking: reasoning } }); } + const text = item.type === "response.output_text.delta" ? recStr(item, "delta") : recStr(delta, "content"); + if (text) { startText(); capture.output++; event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "text_delta", text } }); } + const addedItem = recObj(item, "item"); + const itemTool = item.type === "response.output_item.added" && addedItem?.type === "function_call" ? addedItem : undefined; + const responseArgs = item.type === "response.function_call_arguments.delta" ? recStr(item, "delta") : undefined; + for (const tool of recObjs(delta, "tool_calls")) { + const key = recNum(tool, "index") ?? 0; + const call = calls.get(key) ?? { id: recStr(tool, "id") ?? `call_${key}`, name: "" }; + const toolId = recStr(tool, "id"); + if (toolId) call.id = toolId; + const toolFunction = recObj(tool, "function"); + const toolName = recStr(toolFunction, "name"); + if (toolName) call.name = toolName; + calls.set(key, call); + openTool(key, call.id, call.name); + const partialJson = recStr(toolFunction, "arguments"); + if (partialJson) event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: partialJson } }); + } + if (itemTool) { + const key = recStr(itemTool, "call_id") ?? recStr(itemTool, "id") ?? `call_${blockIndex}`; + const name = recStr(itemTool, "name") ?? ""; + calls.set(key, { id: key, name }); + openTool(key, key, name); + } + if (responseArgs) { + const key = recStr(item, "call_id") ?? recStr(item, "item_id") ?? activeTool ?? ""; + const known = calls.get(key); + // Anthropic tool-use ids are strings; a Responses event that carries + // none falls back to a numeric chat index, which must not leak through. + openTool(key, String(key), recStr(item, "name") ?? known?.name ?? ""); + event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: responseArgs } }); + } + const responseUsage = recObj(recObj(item, "response"), "usage"); + if (responseUsage) responses.usage(responseUsage); + const itemUsage = recObj(item, "usage"); + if (itemUsage) chat.usage(itemUsage); + } catch (error) { + // In-band upstream failures must end the stream; only parse noise is ignored. + if (error instanceof UpstreamFailure) throw error; } - if (typeof responseArgs === "string") { - const key = item.call_id ?? item.item_id ?? activeTool ?? ""; - const known = calls.get(key); - openTool(key, key, item.name ?? known?.name ?? ""); - event(response, "content_block_delta", { type: "content_block_delta", index: blockIndex, delta: { type: "input_json_delta", partial_json: responseArgs } }); + }; + try { + await drain(reader, consume); + if (sawChatShape && !sawFinishReason && !sawDone) { + const message = "Upstream chat completions stream ended before a finish_reason or [DONE] was received."; + options?.onDiagnostic?.(message); + throw new UpstreamFailure(message); } - if (item.response?.usage) { inputTokens = item.response.usage.input_tokens ?? inputTokens; outputTokens = item.response.usage.output_tokens ?? outputTokens; lastUsage = item.response.usage; sawUsage = true; } - if (item.usage) { inputTokens = item.usage.prompt_tokens ?? inputTokens; outputTokens = item.usage.completion_tokens ?? outputTokens; lastUsage = item.usage; sawUsage = true; } + if (!blockStarted) startText(); + stopBlock(); + const stopReason = truncated ? "max_tokens" : toolStop ? "tool_use" : "end_turn"; + const { cached } = cacheTokensOf(capture.raw); + event(response, "message_delta", { type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: capture.output, input_tokens: capture.input, cache_creation_input_tokens: 0, cache_read_input_tokens: cached ?? 0 } }); + event(response, "message_stop", { type: "message_stop" }); } catch (error) { - // In-band upstream failures must end the stream; only parse noise is ignored. - if (error instanceof UpstreamFailure) throw error; - } - }; - try { - await drain(reader, consume); - if (sawChatShape && !sawFinishReason && !sawDone) { - const message = "Upstream chat completions stream ended before a finish_reason or [DONE] was received."; - options?.onDiagnostic?.(message); - throw new UpstreamFailure(message); + // Match Anthropic semantics: a terminal error event closes the stream. + stopBlock(); + emitAnthropicError(response, error); } - if (!blockStarted) startText(); - stopBlock(); - const stopReason = truncated ? "max_tokens" : toolStop ? "tool_use" : "end_turn"; - const { cached } = cacheTokensOf(lastUsage); - event(response, "message_delta", { type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: outputTokens, input_tokens: inputTokens, cache_creation_input_tokens: 0, cache_read_input_tokens: cached ?? 0 } }); - event(response, "message_stop", { type: "message_stop" }); - } catch (error) { - // Match Anthropic semantics: a terminal error event closes the stream. - stopBlock(); - event(response, "error", { type: "error", error: { type: "api_error", message: errorMessage(error) } }); - } - response.end(); - reportUsage(options, sawUsage, inputTokens, outputTokens, lastUsage); - } finally { stopHeartbeat(); } + response.end(); + reportUsage(options, capture); + }); } diff --git a/src/ui.ts b/src/ui.ts index f3072d6..5101b61 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -21,7 +21,6 @@ function stdio() { return { input: io.input, output: io.output }; } * Test-only seam: point every prompt this module renders at fake streams * instead of real process stdio, so the interactive flows (Add/Remove custom * provider, provider/model pickers, …) are actually drivable in tests. - * Mirrors the `setCachedOpenRouter` isolation seam in providers/registry.ts. */ export function __setTestIO(next: { input: NodeJS.ReadStream; output: NodeJS.WriteStream }): void { io = next; } /** Restore real process stdio after a test that called `__setTestIO`. */ @@ -437,7 +436,7 @@ async function selectModel(provider: string, current: string): Promise SqliteDatabase; } +type SqlValue = string | number | bigint | null; +/** node:sqlite returns one object per row; our aggregates alias every numeric column to its `TokenUsageRow` field name. */ +type SqlRow = { [column: string]: SqlValue }; +interface SqliteStatement { + run(...params: SqlValue[]): void; + get(...params: SqlValue[]): SqlRow | undefined; + all(...params: SqlValue[]): SqlRow[]; +} interface SqliteDatabase { exec(sql: string): void; - prepare(sql: string): { run(...args: any[]): void; get(...args: any[]): any; all(...args: any[]): any[] }; + prepare(sql: string): SqliteStatement; close(): void; } +/** Sums arrive as numbers, or as BigInts once a column outgrows the safe double range. */ +function num(value: SqlValue | undefined): number { return Number(value ?? 0); } + let sqlitePromise: Promise | undefined; async function loadSqlite(): Promise { if (sqlitePromise === undefined) sqlitePromise = import("node:sqlite").then((mod) => mod as unknown as SqliteApi).catch(() => undefined); @@ -30,7 +41,7 @@ export function sqliteAvailable(): Promise { return loadSqlite().then(( export class SqliteUsageStore implements UsageStore { private readonly db: SqliteDatabase; - private readonly insert: { run(...args: any[]): void }; + private readonly insert: SqliteStatement; constructor(db: SqliteDatabase) { this.db = db; @@ -49,7 +60,7 @@ export class SqliteUsageStore implements UsageStore { created_at INTEGER NOT NULL )`); // Older databases predate the cache_write_tokens column. - const columns = (this.db.prepare(`PRAGMA table_info(token_usage)`).all() as any[]).map((column) => column.name); + const columns = this.db.prepare(`PRAGMA table_info(token_usage)`).all().map((column) => String(column.name)); if (!columns.includes("cache_write_tokens")) { this.db.exec(`ALTER TABLE token_usage ADD COLUMN cache_write_tokens INTEGER NOT NULL DEFAULT 0`); } @@ -71,26 +82,26 @@ export class SqliteUsageStore implements UsageStore { } async sessionTotals(sessionId: string): Promise { - const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(total_tokens),0) AS totalTokens FROM token_usage WHERE session_id = ?`).get(sessionId) as any; - return { inputTokens: Number(row.inputTokens), outputTokens: Number(row.outputTokens), totalTokens: Number(row.totalTokens) }; + const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(total_tokens),0) AS totalTokens FROM token_usage WHERE session_id = ?`).get(sessionId); + return { inputTokens: num(row?.inputTokens), outputTokens: num(row?.outputTokens), totalTokens: num(row?.totalTokens) }; } async providerStats(period?: UsagePeriod): Promise { const { clause, args } = this.where(period); - const rows = this.db.prepare(`SELECT provider, COALESCE(SUM(total_tokens),0) AS tokens, COUNT(*) AS requests FROM token_usage ${clause} GROUP BY provider ORDER BY tokens DESC, provider ASC`).all(...args) as any[]; - return rows.map((row) => ({ provider: row.provider, tokens: Number(row.tokens), requests: Number(row.requests) })); + const rows = this.db.prepare(`SELECT provider, COALESCE(SUM(total_tokens),0) AS tokens, COUNT(*) AS requests FROM token_usage ${clause} GROUP BY provider ORDER BY tokens DESC, provider ASC`).all(...args); + return rows.map((row) => ({ provider: String(row.provider), tokens: num(row.tokens), requests: num(row.requests) })); } async modelStats(period?: UsagePeriod): Promise { const { clause, args } = this.where(period); - const rows = this.db.prepare(`SELECT provider, model, COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(cached_tokens),0) AS cachedTokens, COALESCE(SUM(cache_write_tokens),0) AS cacheWriteTokens, COALESCE(SUM(reasoning_tokens),0) AS reasoningTokens, COALESCE(SUM(total_tokens),0) AS tokens, COUNT(*) AS requests FROM token_usage ${clause} GROUP BY provider, model ORDER BY tokens DESC, provider ASC, model ASC`).all(...args) as any[]; - return rows.map((row) => ({ provider: row.provider, model: row.model, inputTokens: Number(row.inputTokens), outputTokens: Number(row.outputTokens), cachedTokens: Number(row.cachedTokens), cacheWriteTokens: Number(row.cacheWriteTokens), reasoningTokens: Number(row.reasoningTokens), tokens: Number(row.tokens), requests: Number(row.requests) })); + const rows = this.db.prepare(`SELECT provider, model, COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(cached_tokens),0) AS cachedTokens, COALESCE(SUM(cache_write_tokens),0) AS cacheWriteTokens, COALESCE(SUM(reasoning_tokens),0) AS reasoningTokens, COALESCE(SUM(total_tokens),0) AS tokens, COUNT(*) AS requests FROM token_usage ${clause} GROUP BY provider, model ORDER BY tokens DESC, provider ASC, model ASC`).all(...args); + return rows.map((row) => ({ provider: String(row.provider), model: String(row.model), inputTokens: num(row.inputTokens), outputTokens: num(row.outputTokens), cachedTokens: num(row.cachedTokens), cacheWriteTokens: num(row.cacheWriteTokens), reasoningTokens: num(row.reasoningTokens), tokens: num(row.tokens), requests: num(row.requests) })); } async totals(period?: UsagePeriod): Promise { const { clause, args } = this.where(period); - const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(total_tokens),0) AS totalTokens FROM token_usage ${clause}`).get(...args) as any; - return { inputTokens: Number(row.inputTokens), outputTokens: Number(row.outputTokens), totalTokens: Number(row.totalTokens) }; + const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS inputTokens, COALESCE(SUM(output_tokens),0) AS outputTokens, COALESCE(SUM(total_tokens),0) AS totalTokens FROM token_usage ${clause}`).get(...args); + return { inputTokens: num(row?.inputTokens), outputTokens: num(row?.outputTokens), totalTokens: num(row?.totalTokens) }; } async close(): Promise { this.db.close(); } @@ -153,7 +164,7 @@ export function defaultUsageLocation(backend: StoreBackend): string { return join(base, backend === "json" ? "usage.json" : "usage.db"); } -export function defaultStoreBackend(): StoreBackend { +function defaultStoreBackend(): StoreBackend { const value = process.env.AGENTX_USAGE_BACKEND; return value === "json" || value === "memory" || value === "sqlite" ? value : "sqlite"; } diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index 6126a31..8ca57c7 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -109,7 +109,7 @@ test("runConfigCommand lists every provider with its credential status in non-in test("runConfigCommand --base-url registers and persists a custom provider without launching", async () => { await runConfigCommand(["--provider", "Config CLI LLM", "--base-url", "http://config.local", "--protocol", "responses", "--model", "cfg-model"]); try { - assert.equal(providerById("config-cli-llm").models[0].endpoint, "http://config.local/responses"); + assert.equal(providerById("config-cli-llm").models[0]?.endpoint, "http://config.local/responses"); assert.deepEqual((await loadCustomProviders())["config-cli-llm"], { name: "Config CLI LLM", baseUrl: "http://config.local", protocol: "responses", model: "cfg-model" }); const output = logs.join("\n"); assert.match(output, /Config CLI LLM configured/); @@ -175,6 +175,14 @@ test("configureMissingProvider returns the key on success and undefined on cance assert.equal(cancelled, undefined); }); +function withTTY(run: () => Promise): Promise { + const stdin = process.stdin as unknown as { isTTY?: boolean }; + const stdout = process.stdout as unknown as { isTTY?: boolean }; + const originalStdinTTY = stdin.isTTY; const originalStdoutTTY = stdout.isTTY; + stdin.isTTY = true; stdout.isTTY = true; + return run().finally(() => { stdin.isTTY = originalStdinTTY; stdout.isTTY = originalStdoutTTY; }); +} + test("launchClient returns the exit code from a successful run", async () => { const code = await launchClient("claude", [], process.env, { runCommand: async () => 0 }); assert.equal(code, 0); @@ -187,60 +195,18 @@ test("launchClient propagates errors that are not a missing executable", async ( ); }); -test("launchClient exits 1 without prompting when the terminal is not interactive", async () => { - const calls: string[] = []; - const code = await launchClient("claude", [], process.env, { - runCommand: async () => { throw new ClientNotFoundError("claude"); }, - confirm: async () => { calls.push("confirm"); return true; }, - runShellCommand: async () => { calls.push("shell"); return 0; }, - }); - assert.equal(code, 1); - assert.deepEqual(calls, []); -}); - -function withTTY(run: () => Promise): Promise { - const stdin = process.stdin as unknown as { isTTY?: boolean }; - const stdout = process.stdout as unknown as { isTTY?: boolean }; - const originalStdinTTY = stdin.isTTY; const originalStdoutTTY = stdout.isTTY; - stdin.isTTY = true; stdout.isTTY = true; - return run().finally(() => { stdin.isTTY = originalStdinTTY; stdout.isTTY = originalStdoutTTY; }); -} - -test("launchClient install-recovery flow: user declines the install", async () => { - let shellCalls = 0; - const code = await withTTY(() => launchClient("claude", [], process.env, { - runCommand: async () => { throw new ClientNotFoundError("claude"); }, - confirm: async () => false, - runShellCommand: async () => { shellCalls++; return 0; }, - })); - assert.equal(code, 1); - assert.equal(shellCalls, 0); - process.exitCode = undefined; -}); - -test("launchClient install-recovery flow: install succeeds but the executable is still missing", async () => { +test("launchClient reports a missing client with the install command and exits 1, even interactively", async () => { const code = await withTTY(() => launchClient("claude", [], process.env, { runCommand: async () => { throw new ClientNotFoundError("claude"); }, - confirm: async () => true, - runShellCommand: async () => 0, - executableExists: async () => false, })); assert.equal(code, 1); + const output = errors.join("\n"); + assert.match(output, /Claude Code not found/); + assert.match(output, /npm install -g @anthropic-ai\/claude-code/); + assert.match(output, /After installing, re-run: agentx claude/); process.exitCode = undefined; }); -test("launchClient install-recovery flow: install succeeds and the retried launch resolves its exit code", async () => { - let runCommandCalls = 0; - const code = await withTTY(() => launchClient("claude", [], process.env, { - runCommand: async () => { runCommandCalls++; if (runCommandCalls === 1) throw new ClientNotFoundError("claude"); return 0; }, - confirm: async () => true, - runShellCommand: async () => 0, - executableExists: async () => true, - })); - assert.equal(code, 0); - assert.equal(runCommandCalls, 2); -}); - test("exec injects OpenAI-shaped env vars only when --client-protocol openai is passed", async () => { process.env.AGENTX_DEEPSEEK_API_KEY = "test-key"; let capturedEnv: NodeJS.ProcessEnv | undefined; @@ -263,7 +229,7 @@ test("exec --base-url registers and persists a custom provider, reusable without const deps = { runCommand: async (_cmd: string, _args: string[], env: NodeJS.ProcessEnv) => { capturedEnv = env; return 0; } }; try { await runClientLaunch("exec", ["--provider", "Bench Exec", "--base-url", "http://bench.local", "--protocol", "responses", "--model", "m1", "--", "some-command"], deps); - assert.equal(providerById("bench-exec").models[0].endpoint, "http://bench.local/responses"); + assert.equal(providerById("bench-exec").models[0]?.endpoint, "http://bench.local/responses"); const persisted = await loadCustomProviders(); assert.deepEqual(persisted["bench-exec"], { name: "Bench Exec", baseUrl: "http://bench.local", protocol: "responses", model: "m1" }); assert.ok(capturedEnv?.ANTHROPIC_AUTH_TOKEN, "the launch itself still proceeds through the adapter as normal"); diff --git a/test/codex-catalog.test.ts b/test/codex-catalog.test.ts index 428ae3c..7f365ea 100644 --- a/test/codex-catalog.test.ts +++ b/test/codex-catalog.test.ts @@ -57,7 +57,7 @@ test("includes a custom provider's free-form model id so Codex can resolve it", registerCustomProvider({ name: "Catalog Custom", baseUrl: "http://catalog.invalid", protocol: "chat-completions" }); const models = catalogModels({ provider: "catalog-custom", model: "deepseek-flash" }, []); assert.deepEqual(models.map((item) => item.model), ["deepseek-flash"]); - assert.equal(models[0].provider, "catalog-custom"); + assert.equal(models[0]?.provider, "catalog-custom"); } finally { unregisterCustomProvider("catalog-custom"); } }); diff --git a/test/convert-shared.test.ts b/test/convert-shared.test.ts new file mode 100644 index 0000000..09140da --- /dev/null +++ b/test/convert-shared.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + anthropicThinking, anthropicToolChoice, chatControlParams, chatThinking, chatToolChoice, + collapseAnthropicContent, imageDataUri, reasoningEffort, responsesToolChoice, samplingParams, + textOfBlocks, toAnthropicImageSource, +} from "../src/convert/shared.js"; + +test("collapseAnthropicContent joins pure-text blocks and keeps mixed arrays as blocks", () => { + assert.equal(collapseAnthropicContent([]), ""); + assert.equal(collapseAnthropicContent([{ type: "text", text: "a" }, { type: "text", text: "b" }]), "ab"); + // A text-only block with no text still counts as text: it joins as "". + assert.equal(collapseAnthropicContent([{ type: "text" }]), ""); + assert.deepEqual(collapseAnthropicContent([{ type: "text", text: "a" }, { type: "image" }]), [{ type: "text", text: "a" }, { type: "image" }]); +}); + +test("textOfBlocks reads one block type and ignores the rest", () => { + const content = [{ type: "text", text: "hello" }, { type: "thinking", thinking: "hm" }, { type: "tool_use", id: "t1" }]; + assert.equal(textOfBlocks(content, "text"), "hello"); + assert.equal(textOfBlocks(content, "thinking"), "hm"); + assert.equal(textOfBlocks([], "text"), ""); + assert.equal(textOfBlocks([{ type: "text" }], "text"), ""); +}); + +test("imageDataUri builds a data URI, passes a remote URL through, and drops what it cannot represent", () => { + assert.equal(imageDataUri({ type: "url", url: "https://x/y.png" }), "https://x/y.png"); + assert.equal(imageDataUri({ type: "base64", media_type: "image/jpeg", data: "AA" }), "data:image/jpeg;base64,AA"); + assert.equal(imageDataUri({ type: "base64", data: "AA" }), "data:image/png;base64,AA"); + assert.equal(imageDataUri({ type: "base64" }), undefined); + assert.equal(imageDataUri({ type: "file", path: "/x" }), undefined); + assert.equal(imageDataUri(undefined), undefined); +}); + +test("toAnthropicImageSource reads a data URI into base64 and leaves other URLs as url sources", () => { + assert.equal(toAnthropicImageSource(undefined), undefined); + assert.equal(toAnthropicImageSource(""), undefined); + assert.deepEqual(toAnthropicImageSource("https://x/y.png"), { type: "url", url: "https://x/y.png" }); + assert.deepEqual(toAnthropicImageSource("data:image/gif;base64,R0lG"), { type: "base64", media_type: "image/gif", data: "R0lG" }); +}); + +test("reasoningEffort collapses vendor effort names onto the Chat Completions scale", () => { + for (const value of ["minimal", "low"]) assert.equal(reasoningEffort({ reasoning: { effort: value } }), "low"); + for (const value of ["medium", "high", "xhigh", "ultracode"]) assert.equal(reasoningEffort({ reasoning: { effort: value } }), "high"); + for (const value of ["max", "ultra"]) assert.equal(reasoningEffort({ reasoning: { effort: value } }), "max"); + assert.equal(reasoningEffort({ reasoning: { effort: "telepathic" } }), undefined); + assert.equal(reasoningEffort({ reasoning: { effort: 5 } }), undefined); + assert.equal(reasoningEffort(undefined), undefined); + // output_config wins over reasoning when both are present. + assert.equal(reasoningEffort({ output_config: { effort: "max" }, reasoning: { effort: "low" } }), "max"); +}); + +test("chatThinking maps Anthropic thinking controls and effort names, and stays absent otherwise", () => { + assert.deepEqual(chatThinking({ thinking: { type: "disabled" } }), { type: "disabled" }); + assert.deepEqual(chatThinking({ thinking: { type: "enabled" } }), { type: "enabled" }); + assert.deepEqual(chatThinking({ thinking: { type: "adaptive" } }), { type: "enabled" }); + assert.deepEqual(chatThinking({ reasoning: { effort: "none" } }), { type: "disabled" }); + assert.deepEqual(chatThinking({ reasoning: { effort: "high" } }), { type: "enabled" }); + assert.equal(chatThinking({ thinking: { type: "sometimes" } }), undefined); + assert.equal(chatThinking({}), undefined); + assert.equal(chatThinking(undefined), undefined); +}); + +test("tool choice converts to each dialect, including both names of a forced tool", () => { + // The two upstream dialects name a forced tool differently: Responses puts + // `name` on the choice, Chat Completions nests it under `function`. + for (const choice of [{ type: "function", name: "bash" }, { type: "function", function: { name: "bash" } }] as const) { + assert.deepEqual(chatToolChoice(choice), { type: "function", function: { name: "bash" } }); + assert.deepEqual(responsesToolChoice(choice), { type: "function", name: "bash" }); + assert.deepEqual(anthropicToolChoice(choice), { type: "tool", name: "bash" }); + } + assert.equal(chatToolChoice("required"), "required"); + assert.equal(responsesToolChoice("any"), "required"); + assert.deepEqual(anthropicToolChoice("required"), { type: "any" }); + assert.deepEqual(anthropicToolChoice("auto"), { type: "auto" }); + // A forced tool with no name is not a choice we can forward, and Anthropic's + // own `{type:"tool"}` shape is not one of the source dialects. + assert.equal(chatToolChoice({ type: "function" }), undefined); + assert.equal(anthropicToolChoice({ type: "function", name: 7 }), undefined); + assert.equal(anthropicToolChoice({ type: "tool", name: "bash" }), undefined); + assert.equal(anthropicToolChoice("gpt-4o"), undefined); + assert.equal(chatToolChoice(42), undefined); + assert.equal(responsesToolChoice(null), undefined); +}); + +test("samplingParams forwards only the knobs the client set", () => { + assert.deepEqual(samplingParams({}), {}); + assert.deepEqual(samplingParams({ temperature: 0, top_p: 1, stop_sequences: ["\n"] }), { temperature: 0, top_p: 1, stop: ["\n"] }); + // An explicit null is a value, not an absent knob. + assert.deepEqual(samplingParams({ temperature: null }), { temperature: null }); +}); + +test("chatControlParams gates DeepSeek-only controls behind the provider check", () => { + const input = { thinking: { type: "enabled" }, reasoning: { effort: "high" }, tool_choice: "required" }; + assert.deepEqual(chatControlParams(input, true), { thinking: { type: "enabled" }, reasoning_effort: "high", tool_choice: "required" }); + assert.deepEqual(chatControlParams(input, false), { tool_choice: "required" }); + assert.deepEqual(chatControlParams({}, false), {}); +}); + +test("anthropicThinking gives each effort a concrete budget, and nothing an unknown one", () => { + assert.deepEqual(anthropicThinking("none"), { type: "disabled" }); + assert.deepEqual(anthropicThinking("minimal"), { type: "enabled", budget_tokens: 1024 }); + assert.deepEqual(anthropicThinking("low"), { type: "enabled", budget_tokens: 4096 }); + assert.deepEqual(anthropicThinking("high"), { type: "enabled", budget_tokens: 16000 }); + assert.deepEqual(anthropicThinking("max"), { type: "enabled", budget_tokens: 32000 }); + assert.equal(anthropicThinking("sometimes"), undefined); + assert.equal(anthropicThinking(undefined), undefined); + assert.equal(anthropicThinking(3), undefined); +}); diff --git a/test/fsutil.test.ts b/test/fsutil.test.ts new file mode 100644 index 0000000..5453578 --- /dev/null +++ b/test/fsutil.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { atomicWriteFile } from "../src/fsutil.js"; + +async function tempDir() { + const dir = await mkdtemp(join(tmpdir(), "agentx-fsutil-")); + return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) }; +} + +test("atomicWriteFile creates the parent directories it needs", async () => { + const { dir, cleanup } = await tempDir(); + try { + const file = join(dir, "nested", "deeper", "state.json"); + await atomicWriteFile(file, '{"ok":true}'); + assert.equal(await readFile(file, "utf8"), '{"ok":true}'); + } finally { await cleanup(); } +}); + +test("atomicWriteFile replaces existing contents and leaves no temp files behind", async () => { + const { dir, cleanup } = await tempDir(); + try { + const file = join(dir, "state.json"); + await atomicWriteFile(file, "first"); + await atomicWriteFile(file, "second"); + assert.equal(await readFile(file, "utf8"), "second"); + assert.deepEqual(await readdir(dir), ["state.json"]); + } finally { await cleanup(); } +}); + +test("atomicWriteFile writes state files owner-only, wherever they live", { + // Windows has no POSIX permission bits, so the mode it reports proves nothing there. + skip: process.platform === "win32" && "file modes are not enforced on Windows", +}, async () => { + const { dir, cleanup } = await tempDir(); + try { + // Runtime state can carry a provider API key, so a group- or world-readable + // copy of it is a leak even on a single-user machine. + await writeFile(join(dir, "seed.txt"), ""); + const file = join(dir, "seed.txt"); + await atomicWriteFile(file, "secret"); + const info = await stat(file); + assert.equal(info.mode & 0o077, 0, `state file is group/world readable: ${(info.mode & 0o777).toString(8)}`); + } finally { await cleanup(); } +}); + +test("concurrent atomicWriteFile calls all land without racing each other's temp file", async () => { + const { dir, cleanup } = await tempDir(); + try { + const file = join(dir, "shared.json"); + await Promise.all(Array.from({ length: 8 }, (_, index) => atomicWriteFile(file, String(index)))); + assert.match(await readFile(file, "utf8"), /^[0-7]$/); + assert.deepEqual(await readdir(dir), ["shared.json"]); + } finally { await cleanup(); } +}); diff --git a/test/json.test.ts b/test/json.test.ts new file mode 100644 index 0000000..a35d434 --- /dev/null +++ b/test/json.test.ts @@ -0,0 +1,65 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { asRecords, isRecord, jsonRecord, parse, recCount, recNum, recObj, recObjs, recStr } from "../src/json.js"; + +test("isRecord accepts objects and rejects arrays, null and primitives", () => { + assert.equal(isRecord({}), true); + assert.equal(isRecord({ a: 1 }), true); + assert.equal(isRecord([]), false); + assert.equal(isRecord(null), false); + assert.equal(isRecord("x"), false); + assert.equal(isRecord(7), false); +}); + +test("jsonRecord narrows any parsed payload to an object to read from", () => { + assert.deepEqual(jsonRecord({ a: 1 }), { a: 1 }); + assert.deepEqual(jsonRecord(null), {}); + assert.deepEqual(jsonRecord([1, 2]), {}); + assert.deepEqual(jsonRecord("text"), {}); + assert.deepEqual(jsonRecord(undefined), {}); +}); + +test("asRecords keeps the object members of an array", () => { + assert.deepEqual(asRecords([{ a: 1 }, "skip", null, { b: 2 }]), [{ a: 1 }, { b: 2 }]); + assert.deepEqual(asRecords(undefined), []); + assert.deepEqual(asRecords("not an array"), []); +}); + +test("string accessors read only their own type", () => { + const payload = { name: "bash", count: 3, flag: true, missing: null }; + assert.equal(recStr(payload, "name"), "bash"); + assert.equal(recStr(payload, "count"), undefined); + assert.equal(recStr(payload, "missing"), undefined); + assert.equal(recStr(undefined, "name"), undefined); + assert.equal(recNum(payload, "count"), 3); + assert.equal(recNum(payload, "name"), undefined); + // A counter is not a number: JSON payloads from several providers carry "123". + assert.equal(recNum({ count: "123" }, "count"), undefined); + assert.equal(recCount({ count: "123" }, "count"), 123); +}); + +test("recCount treats absent, null and non-numeric values as no count", () => { + assert.equal(recCount({ a: null }, "a"), undefined); + assert.equal(recCount({ a: "" }, "a"), undefined); + assert.equal(recCount({ a: "abc" }, "a"), undefined); + assert.equal(recCount({ a: NaN }, "a"), undefined); + assert.equal(recCount({}, "a"), undefined); + assert.equal(recCount({ a: 0 }, "a"), 0); +}); + +test("object accessors read nested payloads and drop the wrong shapes", () => { + const payload = { usage: { input_tokens: 5 }, items: [{ id: "a" }, "skip"], list: [1, 2] }; + assert.deepEqual(recObj(payload, "usage"), { input_tokens: 5 }); + assert.equal(recObj(payload, "items"), undefined); + assert.equal(recObj(payload, "missing"), undefined); + assert.deepEqual(recObjs(payload, "items"), [{ id: "a" }]); + assert.deepEqual(recObjs(payload, "list"), []); + assert.deepEqual(recObjs(payload, "usage"), []); +}); + +test("parse is best-effort and never throws", () => { + assert.deepEqual(parse('{"a":1}'), { a: 1 }); + assert.deepEqual(parse({ a: 1 }), { a: 1 }); + assert.deepEqual(parse("not json"), {}); + assert.deepEqual(parse(undefined), {}); +}); diff --git a/test/providers-registry.test.ts b/test/providers-registry.test.ts index e614f4f..3508e49 100644 --- a/test/providers-registry.test.ts +++ b/test/providers-registry.test.ts @@ -386,8 +386,8 @@ test("registerCustomProvider avoids clobbering a built-in provider id, but is id registerCustomProvider({ name: "Repeat Me", baseUrl: "http://b", protocol: "responses", model: "two" }); const matches = providerRegistry.filter((entry) => entry.id === "repeat-me"); assert.equal(matches.length, 1); - assert.equal(matches[0].models[0].endpoint, "http://b/responses"); - assert.equal(matches[0].models[0].model, "two"); + assert.equal(matches[0]?.models[0]?.endpoint, "http://b/responses"); + assert.equal(matches[0]?.models[0]?.model, "two"); } finally { if (shadowedId) unregisterCustomProvider(shadowedId); unregisterCustomProvider("repeat-me"); } }); diff --git a/test/streaming.test.ts b/test/streaming.test.ts index 0b40a15..d715d0c 100644 --- a/test/streaming.test.ts +++ b/test/streaming.test.ts @@ -258,8 +258,8 @@ test("pipeAnthropicPassthrough forwards a native Anthropic SSE stream byte-for-b await pipeAnthropicPassthrough(upstream, output as never, "claude-x", { provider: "custom", model: "claude-x", protocol: "anthropic", onUsage: (usage) => usages.push(usage) }); // Byte-faithful: every upstream chunk appears verbatim in the forwarded output. for (const chunk of chunks) assert.ok(output.text.includes(chunk)); - assert.equal(usages[0].inputTokens, 10); - assert.equal(usages[0].outputTokens, 5); + assert.equal(usages[0]?.inputTokens, 10); + assert.equal(usages[0]?.outputTokens, 5); }); test("pipeAnthropicPassthrough emits an Anthropic error event on upstream failure", async () => { @@ -331,9 +331,9 @@ test("pipeAnthropicStreamToResponses reports input/output/cached usage from mess const output = sink(); const usages: TokenUsage[] = []; await pipeAnthropicStreamToResponses(upstream, output as never, "claude-x", { provider: "custom", model: "claude-x", protocol: "anthropic", onUsage: (usage) => usages.push(usage) }); - assert.equal(usages[0].inputTokens, 50); - assert.equal(usages[0].outputTokens, 9); - assert.equal(usages[0].cachedInputTokens, 40); + assert.equal(usages[0]?.inputTokens, 50); + assert.equal(usages[0]?.outputTokens, 9); + assert.equal(usages[0]?.cachedInputTokens, 40); }); test("pipeAnthropicStreamToResponses emits response.failed when the upstream stream fails mid-flight", async () => { diff --git a/test/ui.test.ts b/test/ui.test.ts index c4ccbaf..dd178d6 100644 --- a/test/ui.test.ts +++ b/test/ui.test.ts @@ -118,8 +118,8 @@ test("Add custom provider: registers it, persists connection metadata only, and try { assert.equal(result, "my-local-llm"); assert.equal(providerById("my-local-llm").custom, true); - assert.equal(providerById("my-local-llm").models[0].protocol, "anthropic"); - assert.equal(providerById("my-local-llm").models[0].endpoint, "http://localhost:11434/v1/messages"); + assert.equal(providerById("my-local-llm").models[0]?.protocol, "anthropic"); + assert.equal(providerById("my-local-llm").models[0]?.endpoint, "http://localhost:11434/v1/messages"); const persisted = await loadCustomProviders(); assert.deepEqual(persisted["my-local-llm"], { name: "My Local LLM", baseUrl: "http://localhost:11434", protocol: "anthropic", model: "custom-model" }); // Never anything key-shaped in the persisted record. @@ -146,8 +146,8 @@ test("Add custom provider: the single Protocol screen maps each choice to the up const result = await resultPromise; try { assert.equal(result, "openai-responses"); - assert.equal(providerById("openai-responses").models[0].protocol, "responses"); - assert.equal(providerById("openai-responses").models[0].endpoint, "https://api.openai.com/v1/responses"); + assert.equal(providerById("openai-responses").models[0]?.protocol, "responses"); + assert.equal(providerById("openai-responses").models[0]?.endpoint, "https://api.openai.com/v1/responses"); } finally { unregisterCustomProvider("openai-responses"); } @@ -195,8 +195,8 @@ test("runProviderManager: adds a custom provider, prints its key guidance, and e await manager; try { - assert.equal(providerById("config-test-llm").models[0].protocol, "chat-completions"); - assert.equal(providerById("config-test-llm").models[0].endpoint, "http://localhost:11434/chat/completions"); + assert.equal(providerById("config-test-llm").models[0]?.protocol, "chat-completions"); + assert.equal(providerById("config-test-llm").models[0]?.endpoint, "http://localhost:11434/chat/completions"); assert.ok((await loadCustomProviders())["config-test-llm"]); // No launch follows, so the manager must print how to persist the key. assert.match(tty.text, /AGENTX_CONFIG_TEST_LLM_API_KEY/); diff --git a/test/usage-storage.test.ts b/test/usage-storage.test.ts index 708a0ac..2e0a8c0 100644 --- a/test/usage-storage.test.ts +++ b/test/usage-storage.test.ts @@ -3,10 +3,10 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createUsageStore, defaultUsageStore, defaultUsageLocation, periodStart, sqliteAvailable } from "../src/usage/storage.js"; +import { createUsageStore, defaultUsageStore, periodStart, sqliteAvailable } from "../src/usage/storage.js"; +import { runUsageStats } from "../src/usage/cli.js"; import { TokenUsageCollector, normalizeUsage } from "../src/usage/collector.js"; import type { TokenUsage } from "../src/usage/types.js"; -import { runUsageStats } from "../src/usage/cli.js"; const now = Date.now(); @@ -110,7 +110,7 @@ test("json file store handles concurrent reads after a write", async () => { const [models, totals] = await Promise.all([store.modelStats("all"), store.totals("all")]); assert.equal(totals.inputTokens, 100); assert.equal(totals.outputTokens, 25); - assert.equal(models[0].tokens, 125); + assert.equal(models[0]?.tokens, 125); await store.close(); } finally { await rm(dir, { recursive: true, force: true }); } }); @@ -135,13 +135,40 @@ test("closing a memory store keeps recorded stats readable", async () => { assert.equal((await store.totals("all")).totalTokens, 150); }); +test("runUsageStats renders the rows the adapter server recorded", async () => { + const originalDir = process.env.AGENTX_USAGE_DIR; + const originalBackend = process.env.AGENTX_USAGE_BACKEND; + const dir = await mkdtemp(join(tmpdir(), "agentx-usage-report-")); + try { + process.env.AGENTX_USAGE_DIR = dir; + process.env.AGENTX_USAGE_BACKEND = "json"; + const store = await defaultUsageStore(); + await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", inputTokens: 400, outputTokens: 100, totalTokens: 500, cachedInputTokens: 120 }))); + await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", inputTokens: 100, outputTokens: 20, totalTokens: 120 }))); + await store.close(); + + const all = await runUsageStats({ period: "all" }); + assert.match(all, /Token Usage \(All time\)/); + assert.match(all, /anthropic/); + assert.match(all, /claude-sonnet-4/); + // 400+100+100+20 recorded tokens, and only the first row carried a cache read. + assert.match(all, /620/); + assert.match(all, /120/); + assert.match(await runUsageStats({ period: "today" }), /Token Usage/); + } finally { + if (originalDir === undefined) delete process.env.AGENTX_USAGE_DIR; else process.env.AGENTX_USAGE_DIR = originalDir; + if (originalBackend === undefined) delete process.env.AGENTX_USAGE_BACKEND; else process.env.AGENTX_USAGE_BACKEND = originalBackend; + await rm(dir, { recursive: true, force: true }); + } +}); + test("cache write tokens survive normalization, storage, and stats", async () => { const store = await createUsageStore({ backend: "memory" }); await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", cachedInputTokens: 500, cacheWriteTokens: 200 }))); await store.record(normalizeUsage(sample({ provider: "anthropic", model: "claude-sonnet-4", cachedInputTokens: 100 }))); const models = await store.modelStats("all"); - assert.equal(models[0].cachedTokens, 600); - assert.equal(models[0].cacheWriteTokens, 200); + assert.equal(models[0]?.cachedTokens, 600); + assert.equal(models[0]?.cacheWriteTokens, 200); await store.close(); }); diff --git a/test/usage-streaming.test.ts b/test/usage-streaming.test.ts index 0574c3e..6576b9f 100644 --- a/test/usage-streaming.test.ts +++ b/test/usage-streaming.test.ts @@ -9,118 +9,127 @@ function collect(options: NonNullable[3]> return { ...options, onUsage: (usage: TokenUsage) => { usages.push(usage); }, usages }; } +/** A stand-in `ServerResponse` whose `writes()` returns everything the pipe emitted. */ +function fakeResponse() { + const chunks: string[] = []; + const response = { writeHead() {}, write(value: string) { chunks.push(value); }, end() {}, on() {} }; + return { response, writes: () => chunks.join("") }; +} + +/** An upstream `Response` replaying `chunks` as its SSE body. */ +function sseUpstream(chunks: string[]) { + return new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); +} + test("pipeResponsesStream reports usage from the final chunk", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"type":"response.output_text.delta","delta":"OK"}\n\n', 'data: {"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response, writes } = fakeResponse(); const opts = collect({ provider: "opencode", model: "gpt-5.6-luna", protocol: "responses" }); - await pipeResponsesStream(upstream, output as never, "gpt-5.6-luna", opts); + await pipeResponsesStream(upstream, response as never, "gpt-5.6-luna", opts); assert.equal(opts.usages.length, 1); assert.deepEqual(opts.usages[0], { provider: "opencode", model: "gpt-5.6-luna", inputTokens: 100, outputTokens: 20, totalTokens: 120 }); + assert.match(writes(), /"type":"text_delta","text":"OK"/); }); test("pipeResponsesStream records cached and reasoning tokens from usage details", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"type":"response.output_text.delta","delta":"OK"}\n\n', - 'data: {"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20,"input_tokens_details":{"cached_tokens":80},"output_tokens_details":{"reasoning_tokens":12}}}}\n\n' - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20,"input_tokens_details":{"cached_tokens":80},"output_tokens_details":{"reasoning_tokens":12}}}}\n\n', + ]); + const { response } = fakeResponse(); const opts = collect({ provider: "opencode", model: "gpt-5.6-luna", protocol: "responses" }); - await pipeResponsesStream(upstream, output as never, "gpt-5.6-luna", opts); + await pipeResponsesStream(upstream, response as never, "gpt-5.6-luna", opts); assert.deepEqual(opts.usages[0], { provider: "opencode", model: "gpt-5.6-luna", inputTokens: 100, outputTokens: 20, totalTokens: 120, cachedInputTokens: 80, reasoningTokens: 12 }); }); test("pipeResponsesStream reads top-level chat completions usage for chat upstreams", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', 'data: {"usage":{"prompt_tokens":50,"completion_tokens":10,"prompt_tokens_details":{"cached_tokens":32}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const opts = collect({ provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions" }); - await pipeResponsesStream(upstream, output as never, "deepseek-v4-pro", opts); + await pipeResponsesStream(upstream, response as never, "deepseek-v4-pro", opts); assert.deepEqual(opts.usages[0], { provider: "deepseek", model: "deepseek-v4-pro", inputTokens: 50, outputTokens: 10, totalTokens: 60, cachedInputTokens: 32 }); }); test("pipeResponsesStream estimates usage when the provider sends none", async () => { - const chunks = ['data: {"type":"response.output_text.delta","delta":"A"}\n\n', 'data: {"type":"response.output_text.delta","delta":"B"}\n\n', "data: [DONE]\n\n"]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + const upstream = sseUpstream([ + 'data: {"type":"response.output_text.delta","delta":"A"}\n\n', + 'data: {"type":"response.output_text.delta","delta":"B"}\n\n', + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const opts = collect({ provider: "opencode", model: "gpt-5.6-luna", protocol: "responses" }); - await pipeResponsesStream(upstream, output as never, "gpt-5.6-luna", opts); - assert.equal(opts.usages[0].estimated, true); - assert.equal(opts.usages[0].outputTokens, 2); + await pipeResponsesStream(upstream, response as never, "gpt-5.6-luna", opts); + assert.equal(opts.usages[0]?.estimated, true); + assert.equal(opts.usages[0]?.outputTokens, 2); }); test("pipeChatStreamToResponses reports usage from the final chunk", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', 'data: {"usage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response, writes } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeChatStreamToResponses(upstream, output as never, "deepseek-v4-pro", { provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); + await pipeChatStreamToResponses(upstream, response as never, "deepseek-v4-pro", { provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); assert.deepEqual(usages[0], { provider: "deepseek", model: "deepseek-v4-pro", inputTokens: 50, outputTokens: 10, totalTokens: 60 }); + assert.match(writes(), /"text":"OK"/); }); test("pipeChatStreamToResponses records cached and reasoning tokens when provided", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"choices":[{"delta":{"content":"OK"}}]}\n\n', 'data: {"usage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60,"prompt_tokens_details":{"cached_tokens":40},"completion_tokens_details":{"reasoning_tokens":6}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeChatStreamToResponses(upstream, output as never, "deepseek-v4-pro", { provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); + await pipeChatStreamToResponses(upstream, response as never, "deepseek-v4-pro", { provider: "deepseek", model: "deepseek-v4-pro", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); assert.deepEqual(usages[0], { provider: "deepseek", model: "deepseek-v4-pro", inputTokens: 50, outputTokens: 10, totalTokens: 60, cachedInputTokens: 40, reasoningTokens: 6 }); }); test("pipeResponsesPassthrough forwards chunks and captures usage", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"type":"response.output_text.delta","delta":"Hi"}\n\n', 'data: {"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":2}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - let text = ""; const output = { writeHead() {}, write(value: string) { text += value; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response, writes } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeResponsesPassthrough(upstream, output as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); - assert.equal(text.includes("output_text"), true); + await pipeResponsesPassthrough(upstream, response as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); + assert.equal(writes().includes("output_text"), true); assert.deepEqual(usages[0], { provider: "opencode", model: "gpt-5.6-luna", inputTokens: 5, outputTokens: 2, totalTokens: 7 }); }); test("pipeResponsesPassthrough records cached and reasoning tokens from usage details", async () => { - const chunks = [ - 'data: {"type":"response.completed","response":{"usage":{"input_tokens":90,"output_tokens":8,"input_tokens_details":{"cached_tokens":70},"output_tokens_details":{"reasoning_tokens":5}}}}\n\n' - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - const output = { writeHead() {}, write() {}, end() {}, on() {} }; + const upstream = sseUpstream([ + 'data: {"type":"response.completed","response":{"usage":{"input_tokens":90,"output_tokens":8,"input_tokens_details":{"cached_tokens":70},"output_tokens_details":{"reasoning_tokens":5}}}}\n\n', + ]); + const { response } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeResponsesPassthrough(upstream, output as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); + await pipeResponsesPassthrough(upstream, response as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); assert.deepEqual(usages[0], { provider: "opencode", model: "gpt-5.6-luna", inputTokens: 90, outputTokens: 8, totalTokens: 98, cachedInputTokens: 70, reasoningTokens: 5 }); }); test("pipeResponsesPassthrough estimates usage from deltas when the provider sends none", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"type":"response.output_text.delta","delta":"A"}\n\n', 'data: {"type":"response.output_text.delta","delta":"B"}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(controller) { for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); controller.close(); } })); - const output = { writeHead() {}, write() {}, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeResponsesPassthrough(upstream, output as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); - assert.equal(usages[0].estimated, true); - assert.equal(usages[0].outputTokens, 2); + await pipeResponsesPassthrough(upstream, response as never, "gpt-5.6-luna", { provider: "opencode", model: "gpt-5.6-luna", protocol: "responses", onUsage: (usage) => usages.push(usage) }); + assert.equal(usages[0]?.estimated, true); + assert.equal(usages[0]?.outputTokens, 2); }); test("renders usage statistics for the CLI", () => { @@ -146,14 +155,13 @@ test("hides the Cached column when no model has cached tokens", () => { }); test("stream pipes capture cache and reasoning tokens from final usage", async () => { - const chunks = [ + const upstream = sseUpstream([ 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', 'data: {"usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":80},"completion_tokens_details":{"reasoning_tokens":3}}}\n\n', - "data: [DONE]\n\n" - ]; - const upstream = new Response(new ReadableStream({ start(c) { for (const ch of chunks) c.enqueue(new TextEncoder().encode(ch)); c.close(); } })); - let text = ""; const output = { writeHead() {}, write(v: string) { text += v; }, end() {}, on() {} }; + "data: [DONE]\n\n", + ]); + const { response } = fakeResponse(); const usages: TokenUsage[] = []; - await pipeChatStreamToResponses(upstream, output as never, "gpt-4o", { provider: "openai", model: "gpt-4o", protocol: "chat-completions", onUsage: (u) => usages.push(u) }); + await pipeChatStreamToResponses(upstream, response as never, "gpt-4o", { provider: "openai", model: "gpt-4o", protocol: "chat-completions", onUsage: (usage) => usages.push(usage) }); assert.deepEqual(usages[0], { provider: "openai", model: "gpt-4o", inputTokens: 100, outputTokens: 5, totalTokens: 105, cachedInputTokens: 80, reasoningTokens: 3 }); }); diff --git a/tsconfig.json b/tsconfig.json index 4bf5f14..78abb91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,9 @@ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": ".", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitOverride": true, + "noUncheckedIndexedAccess": true }, "include": ["src/**/*.ts", "test/**/*.ts"] }