chore: update merge branch with latest v2
This commit is contained in:
commit
7d07f4dfc1
516 changed files with 28957 additions and 14050 deletions
5
.changeset/calm-sessions-header.md
Normal file
5
.changeset/calm-sessions-header.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@opencode-ai/cli": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Expose a TUI plugin slot at the top of the session view.
|
||||||
7
.changeset/clean-sessions-generate.md
Normal file
7
.changeset/clean-sessions-generate.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
---
|
||||||
|
"@opencode-ai/client": patch
|
||||||
|
"@opencode-ai/plugin": patch
|
||||||
|
"@opencode-ai/protocol": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
|
||||||
5
.changeset/fresh-composers-slot.md
Normal file
5
.changeset/fresh-composers-slot.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
"@opencode-ai/cli": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Expose a TUI plugin slot above the session composer.
|
||||||
56
.github/workflows/publish.yml
vendored
56
.github/workflows/publish.yml
vendored
|
|
@ -121,6 +121,55 @@ jobs:
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ needs.version.outputs.version }}
|
version: ${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
|
build-node-cli:
|
||||||
|
needs: version
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
settings:
|
||||||
|
- target: linux-arm64
|
||||||
|
host: blacksmith-4vcpu-ubuntu-2404-arm
|
||||||
|
- target: linux-x64
|
||||||
|
host: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
- target: darwin-arm64
|
||||||
|
host: macos-26
|
||||||
|
- target: windows-arm64
|
||||||
|
host: blacksmith-4vcpu-windows-2025
|
||||||
|
- target: windows-x64
|
||||||
|
host: blacksmith-4vcpu-windows-2025
|
||||||
|
runs-on: ${{ matrix.settings.host }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
with:
|
||||||
|
install-flags: --os=* --cpu=*
|
||||||
|
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "26.4.0"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
|
||||||
|
- name: Verify service lifecycle
|
||||||
|
if: matrix.settings.target != 'windows-arm64'
|
||||||
|
working-directory: packages/cli
|
||||||
|
run: bun run script/service-smoke.ts --node
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: opencode-node-cli-${{ matrix.settings.target }}
|
||||||
|
path: packages/cli/dist/node/cli-node-*
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
sign-cli-windows:
|
sign-cli-windows:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
|
|
@ -413,6 +462,7 @@ jobs:
|
||||||
needs:
|
needs:
|
||||||
- version
|
- version
|
||||||
- build-cli
|
- build-cli
|
||||||
|
- build-node-cli
|
||||||
- sign-cli-windows
|
- sign-cli-windows
|
||||||
- build-electron
|
- build-electron
|
||||||
if: always() && !failure() && !cancelled()
|
if: always() && !failure() && !cancelled()
|
||||||
|
|
@ -461,6 +511,12 @@ jobs:
|
||||||
name: opencode-preview-cli
|
name: opencode-preview-cli
|
||||||
path: packages/cli/dist
|
path: packages/cli/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
with:
|
||||||
|
pattern: opencode-node-cli-*
|
||||||
|
path: packages/cli/dist/node
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
if: needs.version.outputs.release
|
if: needs.version.outputs.release
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
|
|
@ -78,6 +78,20 @@ jobs:
|
||||||
bun run script/build.ts --single --skip-install
|
bun run script/build.ts --single --skip-install
|
||||||
bun run script/service-smoke.ts
|
bun run script/service-smoke.ts
|
||||||
|
|
||||||
|
- name: Setup Node build runtime
|
||||||
|
if: always()
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "26.4.0"
|
||||||
|
|
||||||
|
- name: Verify Node build
|
||||||
|
if: always()
|
||||||
|
timeout-minutes: 15
|
||||||
|
working-directory: packages/cli
|
||||||
|
run: |
|
||||||
|
bun run script/build-node.ts --single --skip-install --outdir=dist/node
|
||||||
|
bun run script/service-smoke.ts --node
|
||||||
|
|
||||||
- name: Check generated client
|
- name: Check generated client
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
working-directory: packages/client
|
working-directory: packages/client
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -11,6 +11,7 @@ node_modules
|
||||||
playground
|
playground
|
||||||
tmp
|
tmp
|
||||||
dist
|
dist
|
||||||
|
dist-node
|
||||||
ts-dist
|
ts-dist
|
||||||
.turbo
|
.turbo
|
||||||
.typecheck-profiles
|
.typecheck-profiles
|
||||||
|
|
@ -25,6 +26,7 @@ Session.vim
|
||||||
a.out
|
a.out
|
||||||
target
|
target
|
||||||
.scripts
|
.scripts
|
||||||
|
.cache
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|
||||||
# Local dev files
|
# Local dev files
|
||||||
|
|
|
||||||
127
bun.lock
127
bun.lock
|
|
@ -132,27 +132,37 @@
|
||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
"@opencode-ai/tui": "workspace:*",
|
"@opencode-ai/tui": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
|
||||||
"uqr": "0.1.3",
|
"uqr": "0.1.3",
|
||||||
|
"ws": "8.21.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-x64": "2.5.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"vite": "catalog:",
|
||||||
|
"vite-plugin-solid": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
|
|
@ -171,7 +181,7 @@
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "4.0.0-beta.83",
|
"effect": "4.0.0-beta.98",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"effect",
|
"effect",
|
||||||
|
|
@ -387,6 +397,7 @@
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"npm-package-arg": "13.0.2",
|
"npm-package-arg": "13.0.2",
|
||||||
|
"resolve.exports": "catalog:",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
"venice-ai-sdk-provider": "2.1.1",
|
"venice-ai-sdk-provider": "2.1.1",
|
||||||
|
|
@ -554,7 +565,7 @@
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.3",
|
"version": "1.18.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.98",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
|
@ -739,9 +750,9 @@
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.4.3",
|
"@opentui/core": ">=0.4.5",
|
||||||
"@opentui/keymap": ">=0.4.3",
|
"@opentui/keymap": ">=0.4.5",
|
||||||
"@opentui/solid": ">=0.4.3",
|
"@opentui/solid": ">=0.4.5",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
|
|
@ -885,9 +896,13 @@
|
||||||
"version": "1.17.13",
|
"version": "1.17.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/commit-mono": "5.2.5",
|
"@fontsource/commit-mono": "5.2.5",
|
||||||
|
"@fontsource/noto-sans-math": "5.2.5",
|
||||||
|
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||||
|
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||||
"@napi-rs/canvas": "1.0.2",
|
"@napi-rs/canvas": "1.0.2",
|
||||||
"@opencode-ai/ai": "workspace:*",
|
"@opencode-ai/ai": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
|
|
@ -1011,6 +1026,7 @@
|
||||||
"@opencode-ai/client": "workspace:*",
|
"@opencode-ai/client": "workspace:*",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/simulation": "workspace:*",
|
"@opencode-ai/simulation": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
|
|
@ -1021,10 +1037,12 @@
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
|
"get-east-asian-width": "catalog:",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
"opentui-spinner": "catalog:",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
|
"string-width": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
"uqr": "0.1.3",
|
"uqr": "0.1.3",
|
||||||
},
|
},
|
||||||
|
|
@ -1163,12 +1181,12 @@
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
|
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
|
|
@ -1180,9 +1198,9 @@
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@corvu/drawer": "0.2.4",
|
"@corvu/drawer": "0.2.4",
|
||||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
"@effect/opentelemetry": "4.0.0-beta.98",
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.98",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
|
@ -1190,9 +1208,9 @@
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.4.3",
|
"@opentui/core": "0.4.5",
|
||||||
"@opentui/keymap": "0.4.3",
|
"@opentui/keymap": "0.4.5",
|
||||||
"@opentui/solid": "0.4.3",
|
"@opentui/solid": "0.4.5",
|
||||||
"@pierre/diffs": "1.2.10",
|
"@pierre/diffs": "1.2.10",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
|
|
@ -1218,8 +1236,9 @@
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.83",
|
"effect": "4.0.0-beta.98",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
"get-east-asian-width": "1.6.0",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
|
|
@ -1228,11 +1247,13 @@
|
||||||
"opentui-spinner": "0.0.7",
|
"opentui-spinner": "0.0.7",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
|
"resolve.exports": "2.0.3",
|
||||||
"semver": "7.7.4",
|
"semver": "7.7.4",
|
||||||
"shiki": "4.2.0",
|
"shiki": "4.2.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
|
"string-width": "7.2.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
|
|
@ -1619,13 +1640,13 @@
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||||
|
|
||||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.83", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.83" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-cPfCfp/ghu0itbX6Dqjdr4N0rbjng5ON4sUpnLHV5JJySG8zZpWmuOZLWIrfrNKT2ctYR1BYmp1aYCgkItaJLw=="],
|
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.98", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.98" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-ITfK8xhcl+9GXOvPwzADWkOQ+dgUGZrJNefT3r2+uLFmzjyKRLtHzhLOl6lZaLSsf5io13+nmt8adfMRQPq+oA=="],
|
||||||
|
|
||||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.83", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.83", "mime": "^4.1.0", "undici": "^8.2.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83", "ioredis": "^5.7.0" } }, "sha512-RmpVGu/+X/Bif3/g1Rzj8oFzTOknoVB3yHCa0b179vytPpKe+Kj9ZwKNcAnKWqHUDkbSPBq1Ca60mvOHr2/+LQ=="],
|
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
|
||||||
|
|
||||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.83", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-+yr/+PJmKTgmJq1QOINSBPgLu7Cjc4CZcotBXnGjyDEizOmimFgTkN2B8PBJAKIKUWYWfobjXqC+58/VhhPKAw=="],
|
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.98", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-iySXaffnCJX1sNAIp79ghhIeui9E5qwUQyqd1VLPkB9UNO4vdpd9B5fTEXwe7S/GusL4jsk9vSvX38XJgRFG1w=="],
|
||||||
|
|
||||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.83", "", { "peerDependencies": { "effect": "^4.0.0-beta.83" } }, "sha512-6OaxLsWffxkh9pXYUSyj/AxjVb9URY2rG9U6atjxClWy30Jx77R9Pm3Rrc7cQ63kQurePavEw1bQbzQ/SILiQQ=="],
|
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.98", "", { "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-cc41uLhYBqexdbTNu4dlui+31E8hcVLEapLySa0C8d60FmBY8IEAV/RD3oF+6pqPslKEZ9p1+XVLdDm0iflw5Q=="],
|
||||||
|
|
||||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||||
|
|
||||||
|
|
@ -1775,6 +1796,12 @@
|
||||||
|
|
||||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||||
|
|
||||||
|
"@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="],
|
||||||
|
|
||||||
|
"@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="],
|
||||||
|
|
||||||
|
"@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="],
|
||||||
|
|
||||||
"@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="],
|
"@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="],
|
||||||
|
|
||||||
"@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ=="],
|
"@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ=="],
|
||||||
|
|
@ -2273,27 +2300,27 @@
|
||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
|
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
|
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
|
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||||
|
|
||||||
"@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
|
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
|
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
|
||||||
|
|
||||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||||
|
|
||||||
|
|
@ -4017,7 +4044,7 @@
|
||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
|
"effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="],
|
||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
|
|
@ -4193,7 +4220,7 @@
|
||||||
|
|
||||||
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
|
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
|
||||||
|
|
||||||
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
|
||||||
|
|
||||||
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||||
|
|
||||||
|
|
@ -5057,7 +5084,7 @@
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
"msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
|
||||||
|
|
||||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||||
|
|
||||||
|
|
@ -5065,7 +5092,7 @@
|
||||||
|
|
||||||
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
|
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
|
||||||
|
|
||||||
"multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
|
"multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
|
||||||
|
|
||||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||||
|
|
||||||
|
|
@ -5595,6 +5622,8 @@
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||||
|
|
||||||
|
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
|
||||||
|
|
||||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||||
|
|
||||||
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
"restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="],
|
||||||
|
|
@ -5963,7 +5992,7 @@
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
"toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
|
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
|
||||||
|
|
||||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
|
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
|
||||||
|
|
||||||
|
|
@ -6049,7 +6078,7 @@
|
||||||
|
|
||||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||||
|
|
||||||
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
"undici": ["undici@8.7.0", "", {}, "sha512-N7iQtfyLhIMOFgQubvmLV26svHpO0bqKnAiWotTQCVKCmWrcGbBotPuW1x+xwYZ2VHdSTVUfPQQnlEt1/LouTQ=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
|
@ -6131,7 +6160,7 @@
|
||||||
|
|
||||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||||
|
|
||||||
"uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
"uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
|
||||||
|
|
||||||
"valibot": ["valibot@1.4.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g=="],
|
"valibot": ["valibot@1.4.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g=="],
|
||||||
|
|
||||||
|
|
@ -8341,6 +8370,26 @@
|
||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-json/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-json/effect/msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-json/effect/multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-json/effect/toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-json/effect/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-openapi/effect/fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-openapi/effect/msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-openapi/effect/multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-openapi/effect/toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
|
||||||
|
|
||||||
|
"@standard-community/standard-openapi/effect/uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
|
||||||
|
|
||||||
"@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
|
"@stoplight/spectral-core/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
|
||||||
|
|
||||||
"@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
"@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||||
|
|
|
||||||
19
package.json
19
package.json
|
|
@ -37,18 +37,18 @@
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
"@effect/opentelemetry": "4.0.0-beta.98",
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.98",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.4.3",
|
"@opentui/core": "0.4.5",
|
||||||
"@opentui/keymap": "0.4.3",
|
"@opentui/keymap": "0.4.5",
|
||||||
"@opentui/solid": "0.4.3",
|
"@opentui/solid": "0.4.5",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
|
|
@ -69,12 +69,13 @@
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.83",
|
"effect": "4.0.0-beta.98",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
|
"get-east-asian-width": "1.6.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.6",
|
"marked": "17.0.6",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
|
|
@ -85,9 +86,11 @@
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
|
"resolve.exports": "2.0.3",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"shiki": "4.2.0",
|
"shiki": "4.2.0",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
|
"string-width": "7.2.0",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"vite": "7.1.4",
|
"vite": "7.1.4",
|
||||||
"@solidjs/meta": "0.29.4",
|
"@solidjs/meta": "0.29.4",
|
||||||
|
|
@ -163,7 +166,7 @@
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# @opencode-ai/ai
|
# @opencode-ai/ai
|
||||||
|
|
||||||
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
|
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
|
@ -24,6 +24,81 @@ const program = Effect.gen(function* () {
|
||||||
|
|
||||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||||
|
|
||||||
|
## Image generation
|
||||||
|
|
||||||
|
Use `Image.generate` with an image model for direct asset generation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Image } from "@opencode-ai/ai"
|
||||||
|
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
size: "1024x1024",
|
||||||
|
quality: "high", // inferred from the OpenAI image model
|
||||||
|
outputFormat: "webp",
|
||||||
|
future_option: true, // unknown native options pass through unchanged
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const model = OpenAI.configure({ apiKey }).image("gpt-image-2")
|
||||||
|
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
options: { quality: "medium" },
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
xAI image models use the same request API with xAI-native controls:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({ apiKey }).image("any-model-id"),
|
||||||
|
prompt,
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
resolution: "1k",
|
||||||
|
responseFormat: "b64_json",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const program = Effect.gen(function* () {
|
||||||
|
const response = yield* LLM.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
|
||||||
|
prompt: "Design a solarpunk rooftop garden, then show me.",
|
||||||
|
tools: [OpenAI.imageGeneration({ quality: "high" })],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.message
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
|
||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||||
|
|
@ -32,6 +107,8 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
|
||||||
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
||||||
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
||||||
|
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
|
||||||
|
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||||
|
|
||||||
## Caching
|
## Caching
|
||||||
|
|
||||||
|
|
@ -182,7 +259,7 @@ Adding a new model or deployment is usually 5-15 lines using `Route.make({ proto
|
||||||
|
|
||||||
## Effect
|
## Effect
|
||||||
|
|
||||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||||
|
|
||||||
## See also
|
## See also
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# LLM Provider Parity Status
|
# LLM Provider Parity Status
|
||||||
|
|
||||||
Last reviewed: 2026-07-16
|
Last reviewed: 2026-07-17
|
||||||
|
|
||||||
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
|
||||||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||||
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
|
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
|
||||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. |
|
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
||||||
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
||||||
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
||||||
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
||||||
"test": "bun test --timeout 30000 --only-failures",
|
"test": "bun test --timeout 30000 --only-failures",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.types.json",
|
||||||
"build": "tsc -p tsconfig.build.json"
|
"build": "tsc -p tsconfig.build.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,18 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||||
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
||||||
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
|
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "minimax",
|
||||||
|
label: "MiniMax",
|
||||||
|
tier: "compatible",
|
||||||
|
note: "Anthropic-compatible Messages text/tool recorded tests",
|
||||||
|
vars: [{ name: "MINIMAX_API_KEY" }],
|
||||||
|
validate: (env) =>
|
||||||
|
HttpClientRequest.get("https://api.minimax.io/anthropic/v1/models").pipe(
|
||||||
|
HttpClientRequest.setHeader("x-api-key", Redacted.value(Redacted.make(env.MINIMAX_API_KEY))),
|
||||||
|
executeRequest,
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "mistral",
|
id: "mistral",
|
||||||
label: "Mistral",
|
label: "Mistral",
|
||||||
|
|
|
||||||
38
packages/ai/src/image-client.ts
Normal file
38
packages/ai/src/image-client.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
import { RequestExecutor } from "./route/executor"
|
||||||
|
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
|
||||||
|
import type { LLMError } from "./schema"
|
||||||
|
|
||||||
|
export type Execute = RequestExecutor.Interface["execute"]
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly generate: <Options extends ImageOptions>(
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
) => Effect.Effect<ImageResponse, LLMError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||||
|
|
||||||
|
export const generate = <Options extends ImageOptions>(
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
): Effect.Effect<ImageResponse, LLMError> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const client = yield* Service
|
||||||
|
return yield* client.generate(request)
|
||||||
|
}) as Effect.Effect<ImageResponse, LLMError>
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const executor = yield* RequestExecutor.Service
|
||||||
|
return Service.of({
|
||||||
|
generate: (request) => request.model.route.generate(request, executor.execute),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ImageClient = {
|
||||||
|
Service,
|
||||||
|
layer,
|
||||||
|
generate,
|
||||||
|
} as const
|
||||||
131
packages/ai/src/image.ts
Normal file
131
packages/ai/src/image.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
|
||||||
|
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
|
||||||
|
|
||||||
|
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
|
||||||
|
readonly id: string
|
||||||
|
readonly generate: (
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
execute: ImageExecute,
|
||||||
|
) => Effect.Effect<ImageResponse, LLMError>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageOptions = Record<string, unknown>
|
||||||
|
|
||||||
|
export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||||
|
declare protected readonly _Options: (options: Options) => Options
|
||||||
|
readonly id: ModelID
|
||||||
|
readonly provider: ProviderID
|
||||||
|
readonly route: ImageRoute<Options>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
|
||||||
|
constructor(input: ImageModel.Input<Options>) {
|
||||||
|
this.id = input.id
|
||||||
|
this.provider = input.provider
|
||||||
|
this.route = input.route
|
||||||
|
this.http = input.http
|
||||||
|
}
|
||||||
|
|
||||||
|
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
|
||||||
|
return new ImageModel<Options>({
|
||||||
|
id: ModelID.make(input.id),
|
||||||
|
provider: ProviderID.make(input.provider),
|
||||||
|
route: input.route,
|
||||||
|
http: input.http,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ImageModel {
|
||||||
|
export interface Input<Options extends ImageOptions = ImageOptions> {
|
||||||
|
readonly id: ModelID
|
||||||
|
readonly provider: ProviderID
|
||||||
|
readonly route: ImageRoute<Options>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MakeInput<Options extends ImageOptions = ImageOptions>
|
||||||
|
extends Omit<Input<Options>, "id" | "provider"> {
|
||||||
|
readonly id: string | ModelID
|
||||||
|
readonly provider: string | ProviderID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||||
|
expected: "Image.Model",
|
||||||
|
})
|
||||||
|
|
||||||
|
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||||
|
model: ImageModelSchema,
|
||||||
|
prompt: Schema.String,
|
||||||
|
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
http: Schema.optional(HttpOptions),
|
||||||
|
}) {
|
||||||
|
declare protected readonly _ImageRequest: void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
|
||||||
|
readonly model: ImageModel<Options>
|
||||||
|
readonly options?: Options
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
|
||||||
|
|
||||||
|
export type ImageRequestInput<Model extends object = ImageModel> = Omit<
|
||||||
|
ConstructorParameters<typeof ImageRequest>[0],
|
||||||
|
"model" | "options" | "http"
|
||||||
|
> & {
|
||||||
|
readonly model: Model
|
||||||
|
readonly options?: NoInfer<ImageModelOptions<Model>>
|
||||||
|
readonly http?: HttpOptions.Input
|
||||||
|
} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
|
||||||
|
|
||||||
|
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
||||||
|
mediaType: Schema.String,
|
||||||
|
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
|
||||||
|
images: Schema.Array(GeneratedImage),
|
||||||
|
usage: Schema.optional(Usage),
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
|
}) {
|
||||||
|
get image() {
|
||||||
|
return this.images[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function request<const Model extends object>(
|
||||||
|
input: ImageRequestInput<Model>,
|
||||||
|
): ImageRequestFor<ImageModelOptions<Model>>
|
||||||
|
export function request(input: ImageRequest): ImageRequest
|
||||||
|
export function request(input: ImageRequest | ImageRequestInput) {
|
||||||
|
if (input instanceof ImageRequest) return input
|
||||||
|
return new ImageRequest({
|
||||||
|
...input,
|
||||||
|
model: input.model as unknown as ImageModel,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generate<const Model extends object>(
|
||||||
|
input: ImageRequestInput<Model>,
|
||||||
|
): Effect.Effect<ImageResponse, LLMError, Service>
|
||||||
|
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service>
|
||||||
|
export function generate(input: ImageRequest | ImageRequestInput) {
|
||||||
|
return Effect.try({
|
||||||
|
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||||
|
catch: (error) =>
|
||||||
|
new LLMError({
|
||||||
|
module: "Image",
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
||||||
|
}),
|
||||||
|
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Image = {
|
||||||
|
request,
|
||||||
|
generate,
|
||||||
|
} as const
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export { LLMClient } from "./route/client"
|
export { LLMClient } from "./route/client"
|
||||||
|
export { ImageClient } from "./image-client"
|
||||||
export { Auth } from "./route/auth"
|
export { Auth } from "./route/auth"
|
||||||
export { Provider } from "./provider"
|
export { Provider } from "./provider"
|
||||||
export { ProviderPackage } from "./provider-package"
|
export { ProviderPackage } from "./provider-package"
|
||||||
|
|
@ -10,6 +11,9 @@ export type {
|
||||||
Service as LLMClientService,
|
Service as LLMClientService,
|
||||||
} from "./route/client"
|
} from "./route/client"
|
||||||
export * from "./schema"
|
export * from "./schema"
|
||||||
|
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse } from "./image"
|
||||||
|
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
|
||||||
|
export { Image } from "./image"
|
||||||
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
||||||
export { ToolRuntime } from "./tool-runtime"
|
export { ToolRuntime } from "./tool-runtime"
|
||||||
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
|
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
|
||||||
|
|
|
||||||
|
|
@ -703,7 +703,14 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||||
providerExecuted: block.type === "server_tool_use",
|
providerExecuted: block.type === "server_tool_use",
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
|
[
|
||||||
|
...events,
|
||||||
|
LLMEvent.toolInputStart({
|
||||||
|
id: block.id ?? String(event.index),
|
||||||
|
name: block.name ?? "",
|
||||||
|
providerExecuted: block.type === "server_tool_use" ? true : undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -561,7 +561,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
|
hasToolCalls:
|
||||||
|
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||||
|
state.hasToolCalls,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
tools: result.tools,
|
tools: result.tools,
|
||||||
reasoningSignatures: Object.fromEntries(
|
reasoningSignatures: Object.fromEntries(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ export * as AnthropicMessages from "./anthropic-messages"
|
||||||
export * as BedrockConverse from "./bedrock-converse"
|
export * as BedrockConverse from "./bedrock-converse"
|
||||||
export * as Gemini from "./gemini"
|
export * as Gemini from "./gemini"
|
||||||
export * as OpenAIChat from "./openai-chat"
|
export * as OpenAIChat from "./openai-chat"
|
||||||
|
export * as OpenAIImages from "./openai-images"
|
||||||
export * as OpenAICompatibleChat from "./openai-compatible-chat"
|
export * as OpenAICompatibleChat from "./openai-compatible-chat"
|
||||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||||
export * as OpenAIResponses from "./openai-responses"
|
export * as OpenAIResponses from "./openai-responses"
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ const OpenAIChatMessage = Schema.Union([
|
||||||
content: Schema.NullOr(Schema.String),
|
content: Schema.NullOr(Schema.String),
|
||||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||||
reasoning_content: Schema.optional(Schema.String),
|
reasoning_content: Schema.optional(Schema.String),
|
||||||
|
reasoning: Schema.optional(Schema.String),
|
||||||
|
reasoning_text: Schema.optional(Schema.String),
|
||||||
|
reasoning_details: optionalArray(Schema.Unknown),
|
||||||
}),
|
}),
|
||||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||||
]).pipe(Schema.toTaggedUnion("role"))
|
]).pipe(Schema.toTaggedUnion("role"))
|
||||||
|
|
@ -145,6 +148,9 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
|
||||||
const OpenAIChatDelta = Schema.Struct({
|
const OpenAIChatDelta = Schema.Struct({
|
||||||
content: optionalNull(Schema.String),
|
content: optionalNull(Schema.String),
|
||||||
reasoning_content: optionalNull(Schema.String),
|
reasoning_content: optionalNull(Schema.String),
|
||||||
|
reasoning: optionalNull(Schema.String),
|
||||||
|
reasoning_text: optionalNull(Schema.String),
|
||||||
|
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
|
||||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -160,12 +166,23 @@ export const OpenAIChatEvent = Schema.Struct({
|
||||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||||
|
|
||||||
|
interface PendingToolDelta {
|
||||||
|
readonly id?: string
|
||||||
|
readonly name?: string
|
||||||
|
readonly input: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParserState {
|
export interface ParserState {
|
||||||
readonly tools: ToolStream.State<number>
|
readonly tools: ToolStream.State<number>
|
||||||
|
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly finishReason?: FinishReason
|
readonly finishReason?: FinishReason
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
|
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
|
||||||
|
readonly reasoningDetails: Array<unknown>
|
||||||
|
readonly reasoningDetailsObserved: boolean
|
||||||
|
readonly reasoningEmitted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -208,6 +225,20 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
|
||||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||||
|
|
||||||
|
const reasoningField = (part: ReasoningPart) => {
|
||||||
|
const field = part.providerMetadata?.openai?.reasoningField
|
||||||
|
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
||||||
|
const observed = parts.flatMap((part) => {
|
||||||
|
const details = part.providerMetadata?.openai?.reasoningDetails
|
||||||
|
return Array.isArray(details) ? details : []
|
||||||
|
})
|
||||||
|
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
|
||||||
|
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||||
|
}
|
||||||
|
|
||||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
|
|
@ -248,14 +279,29 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const text = reasoning.map((part) => part.text).join("")
|
||||||
|
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
|
||||||
|
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
|
||||||
|
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||||
|
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||||
|
const field = (() => {
|
||||||
|
if (reasoning.length === 0) return
|
||||||
|
if (observedField !== undefined) return observedField
|
||||||
|
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||||
|
if (!fullyStructured) return "reasoning_content"
|
||||||
|
})()
|
||||||
|
const reasoningContent = (() => {
|
||||||
|
if (reasoning.length === 0) return nativeReasoning
|
||||||
|
if (field === "reasoning_content") return text
|
||||||
|
})()
|
||||||
return {
|
return {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||||
reasoning_content:
|
reasoning_content: reasoningContent,
|
||||||
reasoning.length > 0
|
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
|
||||||
? reasoning.map((part) => part.text).join("")
|
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
|
||||||
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
|
reasoning_details: details,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -400,6 +446,65 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
|
||||||
|
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
|
||||||
|
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
|
||||||
|
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailText = (details: ReadonlyArray<unknown>) => {
|
||||||
|
const text = details.flatMap((detail) => {
|
||||||
|
if (!isRecord(detail)) return []
|
||||||
|
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
|
||||||
|
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
|
||||||
|
return [detail.summary]
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
if (text.length > 0) return text.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
|
||||||
|
for (const detail of details) {
|
||||||
|
const previous = result.at(-1)
|
||||||
|
if (
|
||||||
|
!isRecord(previous) ||
|
||||||
|
previous.type !== "reasoning.text" ||
|
||||||
|
!isRecord(detail) ||
|
||||||
|
detail.type !== "reasoning.text" ||
|
||||||
|
conflictingReasoningTextDetails(previous, detail)
|
||||||
|
) {
|
||||||
|
result.push(detail)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[result.length - 1] = {
|
||||||
|
...previous,
|
||||||
|
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
|
||||||
|
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
|
||||||
|
signature: mergeDetailValue(previous.signature, detail.signature),
|
||||||
|
format: mergeDetailValue(previous.format, detail.format),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeDetailValue = (previous: unknown, current: unknown) =>
|
||||||
|
previous || current || (previous !== undefined ? previous : current)
|
||||||
|
|
||||||
|
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
|
||||||
|
conflictingDetailValue(previous.id, current.id) ||
|
||||||
|
conflictingDetailValue(previous.index, current.index) ||
|
||||||
|
conflictingDetailValue(previous.format, current.format) ||
|
||||||
|
(Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
|
||||||
|
|
||||||
|
const conflictingDetailValue = (previous: unknown, current: unknown) =>
|
||||||
|
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
|
||||||
|
|
||||||
|
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
|
||||||
|
openai: {
|
||||||
|
...(field ? { reasoningField: field } : {}),
|
||||||
|
...(details ? { reasoningDetails: details } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
|
|
@ -409,25 +514,56 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
const delta = choice?.delta
|
const delta = choice?.delta
|
||||||
const toolDeltas = delta?.tool_calls ?? []
|
const toolDeltas = delta?.tool_calls ?? []
|
||||||
let tools = state.tools
|
let tools = state.tools
|
||||||
|
let pendingTools = state.pendingTools
|
||||||
|
|
||||||
let lifecycle = state.lifecycle
|
let lifecycle = state.lifecycle
|
||||||
|
|
||||||
if (delta?.reasoning_content)
|
const reasoning = reasoningDelta(delta)
|
||||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
||||||
|
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||||
|
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||||
|
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||||
|
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||||
|
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||||
|
if (!state.lifecycle.text.has("text-0") && text !== undefined)
|
||||||
|
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||||
|
else if (
|
||||||
|
reasoningDetailsObserved &&
|
||||||
|
!lifecycle.reasoning.has("reasoning-0") &&
|
||||||
|
(Boolean(delta?.content) || toolDeltas.length > 0)
|
||||||
|
)
|
||||||
|
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||||
|
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||||
|
|
||||||
if (delta?.content) {
|
if (delta?.content) {
|
||||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
lifecycle = Lifecycle.reasoningEnd(
|
||||||
|
lifecycle,
|
||||||
|
events,
|
||||||
|
"reasoning-0",
|
||||||
|
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||||
|
)
|
||||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
|
||||||
|
|
||||||
for (const tool of toolDeltas) {
|
for (const tool of toolDeltas) {
|
||||||
|
const current = tools[tool.index]
|
||||||
|
const pending = pendingTools[tool.index]
|
||||||
|
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||||
|
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
|
||||||
|
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
|
||||||
|
if (!current && (!id || !name)) {
|
||||||
|
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (pending) {
|
||||||
|
pendingTools = { ...pendingTools }
|
||||||
|
delete pendingTools[tool.index]
|
||||||
|
}
|
||||||
const result = ToolStream.appendOrStart(
|
const result = ToolStream.appendOrStart(
|
||||||
ADAPTER,
|
ADAPTER,
|
||||||
tools,
|
tools,
|
||||||
tool.index,
|
tool.index,
|
||||||
{ id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
|
{ id: id || undefined, name: name || undefined, text },
|
||||||
"OpenAI Chat tool call delta is missing id or name",
|
"OpenAI Chat tool call delta is missing id or name",
|
||||||
)
|
)
|
||||||
if (ToolStream.isError(result)) return yield* result
|
if (ToolStream.isError(result)) return yield* result
|
||||||
|
|
@ -436,8 +572,11 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
events.push(...result.events)
|
events.push(...result.events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
|
||||||
|
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
|
||||||
|
|
||||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||||
// JSON parse failures fail the stream at the boundary rather than at halt.
|
// valid calls and malformed local calls settle independently.
|
||||||
const finished =
|
const finished =
|
||||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||||
|
|
@ -446,10 +585,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tools: finished?.tools ?? tools,
|
tools: finished?.tools ?? tools,
|
||||||
|
pendingTools,
|
||||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||||
usage,
|
usage,
|
||||||
finishReason,
|
finishReason,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
reasoningField,
|
||||||
|
reasoningDetails: state.reasoningDetails,
|
||||||
|
reasoningDetailsObserved,
|
||||||
|
reasoningEmitted,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
] as const
|
] as const
|
||||||
|
|
@ -459,7 +603,16 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const hasToolCalls = state.toolCallEvents.length > 0
|
const hasToolCalls = state.toolCallEvents.length > 0
|
||||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||||
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
const metadata = reasoningMetadata(
|
||||||
|
state.reasoningField,
|
||||||
|
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||||
|
)
|
||||||
|
const started =
|
||||||
|
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||||
|
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||||
|
: state.lifecycle
|
||||||
|
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||||
|
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||||
events.push(...state.toolCallEvents)
|
events.push(...state.toolCallEvents)
|
||||||
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||||
return events
|
return events
|
||||||
|
|
@ -482,7 +635,16 @@ export const protocol = Protocol.make({
|
||||||
},
|
},
|
||||||
stream: {
|
stream: {
|
||||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||||
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
|
initial: () => ({
|
||||||
|
tools: ToolStream.empty<number>(),
|
||||||
|
pendingTools: {},
|
||||||
|
toolCallEvents: [],
|
||||||
|
lifecycle: Lifecycle.initial(),
|
||||||
|
reasoningField: undefined,
|
||||||
|
reasoningDetails: [],
|
||||||
|
reasoningDetailsObserved: false,
|
||||||
|
reasoningEmitted: false,
|
||||||
|
}),
|
||||||
step,
|
step,
|
||||||
onHalt: finishEvents,
|
onHalt: finishEvents,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
170
packages/ai/src/protocols/openai-images.ts
Normal file
170
packages/ai/src/protocols/openai-images.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { ImageModel, GeneratedImage, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
LLMError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
} from "../schema"
|
||||||
|
import { ProviderShared } from "./shared"
|
||||||
|
import { OpenAIImage } from "./utils/openai-image"
|
||||||
|
|
||||||
|
const ADAPTER = "openai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
|
||||||
|
export type OpenAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type OpenAIImageOptions = {
|
||||||
|
readonly n?: number
|
||||||
|
readonly size?: OpenAIImageString<
|
||||||
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
|
>
|
||||||
|
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||||
|
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||||
|
readonly moderation?: OpenAIImageString<"auto" | "low">
|
||||||
|
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||||
|
readonly outputCompression?: number
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
export type OpenAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const OpenAIImageResponse = Schema.Struct({
|
||||||
|
data: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
b64_json: Schema.optional(Schema.String),
|
||||||
|
url: Schema.optional(Schema.String),
|
||||||
|
revised_prompt: Schema.optional(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
output_format: Schema.optional(Schema.String),
|
||||||
|
usage: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
input_tokens: Schema.optional(Schema.Number),
|
||||||
|
output_tokens: Schema.optional(Schema.Number),
|
||||||
|
total_tokens: Schema.optional(Schema.Number),
|
||||||
|
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: Record<string, unknown> | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { outputFormat, outputCompression, ...native } = options
|
||||||
|
return {
|
||||||
|
output_format: outputFormat,
|
||||||
|
output_compression: outputCompression,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new LLMError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<OpenAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{ model: request.model.id, prompt: request.prompt },
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as OpenAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const format =
|
||||||
|
decoded.output_format ?? (typeof requestBody.output_format === "string" ? requestBody.output_format : "png")
|
||||||
|
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||||
|
if (item.b64_json)
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: `image/${format}`,
|
||||||
|
data,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (item.url)
|
||||||
|
return Effect.succeed(
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType: `image/${format}`,
|
||||||
|
data: item.url,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||||
|
})
|
||||||
|
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage:
|
||||||
|
decoded.usage === undefined
|
||||||
|
? undefined
|
||||||
|
: new Usage({
|
||||||
|
inputTokens: decoded.usage.input_tokens,
|
||||||
|
outputTokens: decoded.usage.output_tokens,
|
||||||
|
totalTokens: decoded.usage.total_tokens,
|
||||||
|
providerMetadata: { openai: decoded.usage },
|
||||||
|
}),
|
||||||
|
providerMetadata: { openai: { outputFormat: format } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
import { Route } from "../route/client"
|
import { Route } from "../route/client"
|
||||||
import { Auth } from "../route/auth"
|
import { Auth } from "../route/auth"
|
||||||
import { Endpoint } from "../route/endpoint"
|
import { Endpoint } from "../route/endpoint"
|
||||||
|
|
@ -25,6 +25,7 @@ import { OpenAIOptions } from "./utils/openai-options"
|
||||||
import { Lifecycle } from "./utils/lifecycle"
|
import { Lifecycle } from "./utils/lifecycle"
|
||||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||||
import { ToolStream } from "./utils/tool-stream"
|
import { ToolStream } from "./utils/tool-stream"
|
||||||
|
import { OpenAIImage } from "./utils/openai-image"
|
||||||
|
|
||||||
const ADAPTER = "openai-responses"
|
const ADAPTER = "openai-responses"
|
||||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
|
|
@ -113,11 +114,24 @@ const OpenAIResponsesTool = Schema.Struct({
|
||||||
parameters: JsonObject,
|
parameters: JsonObject,
|
||||||
strict: Schema.optional(Schema.Boolean),
|
strict: Schema.optional(Schema.Boolean),
|
||||||
})
|
})
|
||||||
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
|
const OpenAIResponsesImageGenerationTool = Schema.Struct({
|
||||||
|
type: Schema.tag("image_generation"),
|
||||||
|
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
|
||||||
|
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
|
||||||
|
input_fidelity: Schema.optional(Schema.Literals(["low", "high"])),
|
||||||
|
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
|
||||||
|
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
|
||||||
|
partial_images: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
|
||||||
|
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
|
||||||
|
size: Schema.optional(OpenAIImage.Size),
|
||||||
|
})
|
||||||
|
const OpenAIResponsesTools = Schema.Union([OpenAIResponsesTool, OpenAIResponsesImageGenerationTool])
|
||||||
|
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTools>
|
||||||
|
|
||||||
const OpenAIResponsesToolChoice = Schema.Union([
|
const OpenAIResponsesToolChoice = Schema.Union([
|
||||||
Schema.Literals(["auto", "none", "required"]),
|
Schema.Literals(["auto", "none", "required"]),
|
||||||
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
|
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
|
||||||
|
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Fields shared between the HTTP body and the WebSocket `response.create`
|
// Fields shared between the HTTP body and the WebSocket `response.create`
|
||||||
|
|
@ -128,7 +142,7 @@ const OpenAIResponsesCoreFields = {
|
||||||
model: Schema.String,
|
model: Schema.String,
|
||||||
input: Schema.Array(OpenAIResponsesInputItem),
|
input: Schema.Array(OpenAIResponsesInputItem),
|
||||||
instructions: Schema.optional(Schema.String),
|
instructions: Schema.optional(Schema.String),
|
||||||
tools: optionalArray(OpenAIResponsesTool),
|
tools: optionalArray(OpenAIResponsesTools),
|
||||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||||
store: Schema.optional(Schema.Boolean),
|
store: Schema.optional(Schema.Boolean),
|
||||||
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
|
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
|
||||||
|
|
@ -194,6 +208,8 @@ const OpenAIResponsesStreamItem = Schema.Struct({
|
||||||
outputs: Schema.optional(Schema.Unknown),
|
outputs: Schema.optional(Schema.Unknown),
|
||||||
server_label: Schema.optional(Schema.String),
|
server_label: Schema.optional(Schema.String),
|
||||||
output: Schema.optional(Schema.Unknown),
|
output: Schema.optional(Schema.Unknown),
|
||||||
|
result: Schema.optional(Schema.String),
|
||||||
|
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
|
||||||
error: Schema.optional(Schema.Unknown),
|
error: Schema.optional(Schema.Unknown),
|
||||||
encrypted_content: optionalNull(Schema.String),
|
encrypted_content: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
@ -258,21 +274,41 @@ const invalid = ProviderShared.invalidRequest
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Request Lowering
|
// Request Lowering
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
|
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||||
type: "function",
|
const native = tool.native?.openai
|
||||||
name: tool.name,
|
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
|
||||||
description: tool.description,
|
}
|
||||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
|
||||||
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
|
const nativeImageTool = (tool: ToolDefinition) => {
|
||||||
strict: false,
|
const native = nativeImageToolInput(tool)
|
||||||
|
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
|
||||||
|
const native = nativeImageToolInput(tool)
|
||||||
|
if (native !== undefined) {
|
||||||
|
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
|
||||||
|
return yield* invalid("OpenAI Responses image generation tool options are invalid")
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "function" as const,
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||||
|
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
|
||||||
|
strict: false,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
|
||||||
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
|
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
|
||||||
auto: () => "auto" as const,
|
auto: () => "auto" as const,
|
||||||
none: () => "none" as const,
|
none: () => "none" as const,
|
||||||
required: () => "required" as const,
|
required: () => "required" as const,
|
||||||
tool: (name) => ({ type: "function" as const, name }),
|
tool: (name) =>
|
||||||
|
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
|
||||||
|
? ({ type: "image_generation" } as const)
|
||||||
|
: { type: "function" as const, name },
|
||||||
})
|
})
|
||||||
|
|
||||||
const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
|
const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
|
||||||
|
|
@ -420,6 +456,13 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
const itemID = hostedToolItemID(part)
|
const itemID = hostedToolItemID(part)
|
||||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||||
input.push({ type: "item_reference", id: itemID })
|
input.push({ type: "item_reference", id: itemID })
|
||||||
|
if (store === false && part.name === "image_generation" && part.result.type === "content") {
|
||||||
|
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||||
|
input.push({
|
||||||
|
role: "user",
|
||||||
|
content: yield* Effect.forEach(content, lowerToolResultContentItem),
|
||||||
|
})
|
||||||
|
}
|
||||||
if (itemID) hostedToolReferences.add(itemID)
|
if (itemID) hostedToolReferences.add(itemID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -485,10 +528,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||||
tools:
|
tools:
|
||||||
request.tools.length === 0
|
request.tools.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
: request.tools.map((tool) =>
|
: yield* Effect.forEach(request.tools, (tool) =>
|
||||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||||
),
|
),
|
||||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
|
||||||
stream: true as const,
|
stream: true as const,
|
||||||
max_output_tokens: generation?.maxTokens,
|
max_output_tokens: generation?.maxTokens,
|
||||||
temperature: generation?.temperature,
|
temperature: generation?.temperature,
|
||||||
|
|
@ -574,14 +617,29 @@ const isReasoningItem = (
|
||||||
|
|
||||||
// Round-trip the full item as the structured result so consumers can extract
|
// Round-trip the full item as the structured result so consumers can extract
|
||||||
// outputs / sources / status without re-decoding.
|
// outputs / sources / status without re-decoding.
|
||||||
const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
|
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: OpenAIResponsesStreamItem) {
|
||||||
const isError = typeof item.error !== "undefined" && item.error !== null
|
const isError = typeof item.error !== "undefined" && item.error !== null
|
||||||
|
if (item.type === "image_generation_call" && item.result) {
|
||||||
|
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||||
|
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
type: "content" as const,
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
type: "file" as const,
|
||||||
|
uri: `data:image/${item.output_format ?? "png"};base64,${item.result}`,
|
||||||
|
mime: `image/${item.output_format ?? "png"}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||||
}
|
})
|
||||||
|
|
||||||
const hostedToolEvents = (
|
const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* (
|
||||||
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
|
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
|
||||||
): ReadonlyArray<LLMEvent> => {
|
) {
|
||||||
const tool = HOSTED_TOOLS[item.type]
|
const tool = HOSTED_TOOLS[item.type]
|
||||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||||
return [
|
return [
|
||||||
|
|
@ -595,12 +653,12 @@ const hostedToolEvents = (
|
||||||
LLMEvent.toolResult({
|
LLMEvent.toolResult({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
result: hostedToolResult(item),
|
result: yield* hostedToolResult(item),
|
||||||
providerExecuted: true,
|
providerExecuted: true,
|
||||||
providerMetadata,
|
providerMetadata,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
}
|
})
|
||||||
|
|
||||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||||
|
|
||||||
|
|
@ -835,7 +893,9 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||||
{
|
{
|
||||||
...state,
|
...state,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
|
hasFunctionCall:
|
||||||
|
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||||
|
state.hasFunctionCall,
|
||||||
tools: result.tools,
|
tools: result.tools,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
|
|
@ -845,7 +905,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||||
if (isHostedToolItem(item)) {
|
if (isHostedToolItem(item)) {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||||
events.push(...hostedToolEvents(item))
|
events.push(...(yield* hostedToolEvents(item)))
|
||||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export const reasoningDelta = (
|
||||||
providerMetadata?: ProviderMetadata,
|
providerMetadata?: ProviderMetadata,
|
||||||
): State => {
|
): State => {
|
||||||
const started = reasoningStart(state, events, id, providerMetadata)
|
const started = reasoningStart(state, events, id, providerMetadata)
|
||||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
20
packages/ai/src/protocols/utils/openai-image.ts
Normal file
20
packages/ai/src/protocols/utils/openai-image.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Schema } from "effect"
|
||||||
|
|
||||||
|
const dimensions = (value: string) => {
|
||||||
|
const match = /^(\d+)x(\d+)$/.exec(value)
|
||||||
|
if (!match) return undefined
|
||||||
|
return { width: Number(match[1]), height: Number(match[2]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Size = Schema.String.check(
|
||||||
|
Schema.makeFilter((value) => {
|
||||||
|
if (value === "auto") return undefined
|
||||||
|
const parsed = dimensions(value)
|
||||||
|
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
|
||||||
|
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const OpenAIImage = {
|
||||||
|
Size,
|
||||||
|
} as const
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
|
||||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||||
|
|
||||||
type StreamKey = string | number
|
type StreamKey = string | number
|
||||||
|
|
@ -53,6 +53,7 @@ const inputStart = (tool: PendingTool) =>
|
||||||
LLMEvent.toolInputStart({
|
LLMEvent.toolInputStart({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
providerMetadata: tool.providerMetadata,
|
providerMetadata: tool.providerMetadata,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -63,19 +64,36 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
||||||
text,
|
text,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
const raw = inputOverride ?? tool.input
|
||||||
Effect.map(
|
return parseToolInput(route, tool.name, raw).pipe(
|
||||||
(input): ToolCall =>
|
Effect.map((input): ToolCall | ToolInputError =>
|
||||||
LLMEvent.toolCall({
|
LLMEvent.toolCall({
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
input,
|
input,
|
||||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||||
providerMetadata: tool.providerMetadata,
|
providerMetadata: tool.providerMetadata,
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
Effect.catch((error) =>
|
||||||
|
tool.providerExecuted
|
||||||
|
? Effect.fail(error)
|
||||||
|
: Effect.succeed(
|
||||||
|
LLMEvent.toolInputError({
|
||||||
|
id: tool.id,
|
||||||
|
name: tool.name,
|
||||||
|
raw,
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||||
|
event.type === "tool-input-error"
|
||||||
|
? [event]
|
||||||
|
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
|
||||||
|
|
||||||
/** Store the updated tool and produce the optional public delta event. */
|
/** Store the updated tool and produce the optional public delta event. */
|
||||||
const appendTool = <K extends StreamKey>(
|
const appendTool = <K extends StreamKey>(
|
||||||
|
|
@ -122,8 +140,8 @@ export const appendOrStart = <K extends StreamKey>(
|
||||||
missingToolMessage: string,
|
missingToolMessage: string,
|
||||||
): AppendOutcome<K> | LLMError => {
|
): AppendOutcome<K> | LLMError => {
|
||||||
const current = tools[key]
|
const current = tools[key]
|
||||||
const id = delta.id ?? current?.id
|
const id = current?.id ?? delta.id
|
||||||
const name = delta.name ?? current?.name
|
const name = current?.name ?? delta.name
|
||||||
if (!id || !name) return eventError(route, missingToolMessage)
|
if (!id || !name) return eventError(route, missingToolMessage)
|
||||||
|
|
||||||
const tool = {
|
const tool = {
|
||||||
|
|
@ -158,8 +176,9 @@ export const appendExisting = <K extends StreamKey>(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||||
* from state, and return the optional public `tool-call` event. Missing keys are
|
* from state, and return either a call or a non-executable local input error.
|
||||||
* a no-op because some providers emit stop events for non-tool content blocks.
|
* Missing keys are a no-op because some providers emit stop events for
|
||||||
|
* non-tool content blocks.
|
||||||
*/
|
*/
|
||||||
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -167,10 +186,7 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
|
||||||
if (!tool) return { tools }
|
if (!tool) return { tools }
|
||||||
return {
|
return {
|
||||||
tools: withoutTool(tools, key),
|
tools: withoutTool(tools, key),
|
||||||
events: [
|
events: finishEvents(tool, yield* toolCall(route, tool)),
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
yield* toolCall(route, tool),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -185,17 +201,14 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
|
||||||
if (!tool) return { tools }
|
if (!tool) return { tools }
|
||||||
return {
|
return {
|
||||||
tools: withoutTool(tools, key),
|
tools: withoutTool(tools, key),
|
||||||
events: [
|
events: finishEvents(tool, yield* toolCall(route, tool, input)),
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
yield* toolCall(route, tool, input),
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
||||||
* not emit per-tool stop events, so all accumulated calls finish when the choice
|
* not emit per-tool stop events, so all accumulated calls finish independently
|
||||||
* receives a terminal `finish_reason`.
|
* when the choice receives a terminal `finish_reason`.
|
||||||
*/
|
*/
|
||||||
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -205,12 +218,7 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
|
||||||
return {
|
return {
|
||||||
tools: empty<K>(),
|
tools: empty<K>(),
|
||||||
events: yield* Effect.forEach(pending, (tool) =>
|
events: yield* Effect.forEach(pending, (tool) =>
|
||||||
toolCall(route, tool).pipe(
|
toolCall(route, tool).pipe(Effect.map((event) => finishEvents(tool, event))),
|
||||||
Effect.map((call) => [
|
|
||||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
|
||||||
call,
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
).pipe(Effect.map((events) => events.flat())),
|
).pipe(Effect.map((events) => events.flat())),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
184
packages/ai/src/protocols/xai-images.ts
Normal file
184
packages/ai/src/protocols/xai-images.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
LLMError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
} from "../schema"
|
||||||
|
import { ProviderShared, optionalNull } from "./shared"
|
||||||
|
|
||||||
|
const ADAPTER = "xai-images"
|
||||||
|
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||||
|
export const PATH = "/images/generations"
|
||||||
|
|
||||||
|
export type XAIImageString<Known extends string> = Known | (string & {})
|
||||||
|
|
||||||
|
export type XAIImageOptions = {
|
||||||
|
readonly n?: number
|
||||||
|
readonly aspectRatio?: XAIImageString<
|
||||||
|
| "1:1"
|
||||||
|
| "3:4"
|
||||||
|
| "4:3"
|
||||||
|
| "9:16"
|
||||||
|
| "16:9"
|
||||||
|
| "2:3"
|
||||||
|
| "3:2"
|
||||||
|
| "9:19.5"
|
||||||
|
| "19.5:9"
|
||||||
|
| "9:20"
|
||||||
|
| "20:9"
|
||||||
|
| "1:2"
|
||||||
|
| "2:1"
|
||||||
|
| "auto"
|
||||||
|
>
|
||||||
|
readonly aspect_ratio?: XAIImageString<
|
||||||
|
| "1:1"
|
||||||
|
| "3:4"
|
||||||
|
| "4:3"
|
||||||
|
| "9:16"
|
||||||
|
| "16:9"
|
||||||
|
| "2:3"
|
||||||
|
| "3:2"
|
||||||
|
| "9:19.5"
|
||||||
|
| "19.5:9"
|
||||||
|
| "9:20"
|
||||||
|
| "20:9"
|
||||||
|
| "1:2"
|
||||||
|
| "2:1"
|
||||||
|
| "auto"
|
||||||
|
>
|
||||||
|
readonly resolution?: XAIImageString<"1k" | "2k">
|
||||||
|
readonly responseFormat?: XAIImageString<"url" | "b64_json">
|
||||||
|
readonly response_format?: XAIImageString<"url" | "b64_json">
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
type XAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const XAIImageResponse = Schema.Struct({
|
||||||
|
data: Schema.Array(
|
||||||
|
Schema.Struct({
|
||||||
|
b64_json: optionalNull(Schema.String),
|
||||||
|
url: optionalNull(Schema.String),
|
||||||
|
revised_prompt: optionalNull(Schema.String),
|
||||||
|
mime_type: optionalNull(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
usage: Schema.optional(Schema.Unknown),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface ModelInput {
|
||||||
|
readonly id: string
|
||||||
|
readonly auth: AuthDefinition
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
readonly http?: HttpOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = (options: XAIImageOptions | undefined) => {
|
||||||
|
if (!options) return undefined
|
||||||
|
const { aspectRatio, responseFormat, ...native } = options
|
||||||
|
return {
|
||||||
|
aspect_ratio: aspectRatio,
|
||||||
|
response_format: responseFormat,
|
||||||
|
...native,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidOutput = (message: string) =>
|
||||||
|
new LLMError({
|
||||||
|
module: ADAPTER,
|
||||||
|
method: "generate",
|
||||||
|
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
|
if (!query) return url
|
||||||
|
const next = new URL(url)
|
||||||
|
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||||
|
return next.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: ModelInput) => {
|
||||||
|
const route: ImageRoute<XAIImageOptions> = {
|
||||||
|
id: ADAPTER,
|
||||||
|
generate: Effect.fn("XAIImages.generate")(function* (request: ImageRequestFor<XAIImageOptions>, execute) {
|
||||||
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
|
const requestBody = mergeJsonRecords(
|
||||||
|
{ model: request.model.id, prompt: request.prompt },
|
||||||
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
|
) as XAIImageBody
|
||||||
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||||
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
|
request,
|
||||||
|
method: "POST",
|
||||||
|
url,
|
||||||
|
body: text,
|
||||||
|
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||||
|
})
|
||||||
|
const response = yield* execute(
|
||||||
|
HttpClientRequest.post(url).pipe(
|
||||||
|
HttpClientRequest.setHeaders(headers),
|
||||||
|
HttpClientRequest.bodyText(text, "application/json"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const payload = yield* response.json.pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("Failed to read the xAI Images response")),
|
||||||
|
)
|
||||||
|
const decoded = yield* Schema.decodeUnknownEffect(XAIImageResponse)(payload).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput("xAI Images returned an invalid response")),
|
||||||
|
)
|
||||||
|
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||||
|
const mediaType = item.mime_type ?? "application/octet-stream"
|
||||||
|
if (item.b64_json)
|
||||||
|
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||||
|
Effect.mapError(() => invalidOutput(`xAI Images result ${index} contains invalid base64 data`)),
|
||||||
|
Effect.map(
|
||||||
|
(data) =>
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType,
|
||||||
|
data,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined || item.revised_prompt === null
|
||||||
|
? undefined
|
||||||
|
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (item.url)
|
||||||
|
return Effect.succeed(
|
||||||
|
new GeneratedImage({
|
||||||
|
mediaType,
|
||||||
|
data: item.url,
|
||||||
|
providerMetadata:
|
||||||
|
item.revised_prompt === undefined || item.revised_prompt === null
|
||||||
|
? undefined
|
||||||
|
: { xai: { revisedPrompt: item.revised_prompt } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return Effect.fail(invalidOutput(`xAI Images result ${index} has neither image data nor a URL`))
|
||||||
|
})
|
||||||
|
if (images.length === 0) return yield* invalidOutput("xAI Images returned no images")
|
||||||
|
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||||
|
return new ImageResponse({
|
||||||
|
images,
|
||||||
|
usage: usage === undefined ? undefined : new Usage({ providerMetadata: { xai: usage } }),
|
||||||
|
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return ImageModel.make<XAIImageOptions>({ id: input.id, provider: "xai", route, http: input.http })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const XAIImages = {
|
||||||
|
model,
|
||||||
|
} as const
|
||||||
|
|
@ -16,13 +16,17 @@ import {
|
||||||
|
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/prompt is too long/i,
|
/prompt is too long/i,
|
||||||
|
/request_too_large/i,
|
||||||
/input is too long for requested model/i,
|
/input is too long for requested model/i,
|
||||||
/exceeds the context window/i,
|
/exceeds the context window/i,
|
||||||
|
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||||
/input token count.*exceeds the maximum/i,
|
/input token count.*exceeds the maximum/i,
|
||||||
/tokens in request more than max tokens allowed/i,
|
/tokens in request more than max tokens allowed/i,
|
||||||
/maximum prompt length is \d+/i,
|
/maximum prompt length is \d+/i,
|
||||||
/reduce the length of the messages/i,
|
/reduce the length of the messages/i,
|
||||||
/maximum context length is \d+ tokens/i,
|
/maximum context length is \d+ tokens/i,
|
||||||
|
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
|
||||||
|
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
|
||||||
/exceeds the limit of \d+/i,
|
/exceeds the limit of \d+/i,
|
||||||
/exceeds the available context size/i,
|
/exceeds the available context size/i,
|
||||||
/greater than the context length/i,
|
/greater than the context length/i,
|
||||||
|
|
@ -34,11 +38,17 @@ const patterns = [
|
||||||
/input length.*exceeds.*context length/i,
|
/input length.*exceeds.*context length/i,
|
||||||
/prompt too long; exceeded (?:max )?context length/i,
|
/prompt too long; exceeded (?:max )?context length/i,
|
||||||
/too large for model with \d+ maximum context length/i,
|
/too large for model with \d+ maximum context length/i,
|
||||||
|
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||||
/model_context_window_exceeded/i,
|
/model_context_window_exceeded/i,
|
||||||
|
/too many tokens/i,
|
||||||
|
/token limit exceeded/i,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||||
|
|
||||||
export const isContextOverflow = (message: string) =>
|
export const isContextOverflow = (message: string) =>
|
||||||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||||
|
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||||
|
|
||||||
export const isContextOverflowFailure = (failure: unknown) =>
|
export const isContextOverflowFailure = (failure: unknown) =>
|
||||||
failure instanceof LLMError
|
failure instanceof LLMError
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { Route, RouteDefaultsInput } from "../route/client"
|
import type { Route, RouteDefaultsInput } from "../route/client"
|
||||||
import type { ProviderPackage } from "../provider-package"
|
import type { ProviderPackage } from "../provider-package"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema"
|
||||||
import * as OpenAIChat from "../protocols/openai-chat"
|
import * as OpenAIChat from "../protocols/openai-chat"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
|
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images"
|
||||||
|
|
||||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||||
|
export type { OpenAIImageOptions } from "../protocols/openai-images"
|
||||||
|
|
||||||
export const id = ProviderID.make("openai")
|
export const id = ProviderID.make("openai")
|
||||||
|
|
||||||
|
|
@ -22,6 +24,39 @@ export type Config = RouteDefaultsInput &
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationOptions {
|
||||||
|
readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
|
||||||
|
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||||
|
readonly inputFidelity?: OpenAIImageString<"low" | "high">
|
||||||
|
readonly outputCompression?: number
|
||||||
|
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||||
|
readonly partialImages?: number
|
||||||
|
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||||
|
readonly size?: OpenAIImageString<
|
||||||
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||||
|
ToolDefinition.make({
|
||||||
|
name: "image_generation",
|
||||||
|
description: "Generate or edit an image using OpenAI's hosted image generation tool.",
|
||||||
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||||
|
native: {
|
||||||
|
openai: {
|
||||||
|
type: "image_generation",
|
||||||
|
action: options.action,
|
||||||
|
background: options.background,
|
||||||
|
input_fidelity: options.inputFidelity,
|
||||||
|
output_compression: options.outputCompression,
|
||||||
|
output_format: options.outputFormat,
|
||||||
|
partial_images: options.partialImages,
|
||||||
|
quality: options.quality,
|
||||||
|
size: options.size,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export interface Settings extends ProviderPackage.Settings {
|
export interface Settings extends ProviderPackage.Settings {
|
||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
|
|
@ -55,6 +90,17 @@ export const configure = (input: Config = {}) => {
|
||||||
const responsesWebSocket = (id: string | ModelID) =>
|
const responsesWebSocket = (id: string | ModelID) =>
|
||||||
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
||||||
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
|
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
OpenAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: mergeHttpOptions(
|
||||||
|
input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -62,6 +108,7 @@ export const configure = (input: Config = {}) => {
|
||||||
responses,
|
responses,
|
||||||
responsesWebSocket,
|
responsesWebSocket,
|
||||||
chat,
|
chat,
|
||||||
|
image,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,3 +144,4 @@ export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const responsesWebSocket = provider.responsesWebSocket
|
export const responsesWebSocket = provider.responsesWebSocket
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
|
export const image = provider.image
|
||||||
|
|
|
||||||
|
|
@ -41,13 +41,31 @@ export const protocol = Protocol.make({
|
||||||
schema: OpenRouterBody,
|
schema: OpenRouterBody,
|
||||||
from: (request) =>
|
from: (request) =>
|
||||||
OpenAIChat.protocol.body.from(request).pipe(
|
OpenAIChat.protocol.body.from(request).pipe(
|
||||||
Effect.map(
|
Effect.map((body) => {
|
||||||
(body) =>
|
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
|
||||||
({
|
let assistantIndex = 0
|
||||||
...body,
|
const messages = body.messages.map((message) => {
|
||||||
...bodyOptions(request.providerOptions?.openrouter),
|
if (message.role !== "assistant") return message
|
||||||
}) as OpenRouterBody,
|
const source = sourceAssistants[assistantIndex++]
|
||||||
),
|
const reasoning = source?.content
|
||||||
|
.filter((part) => part.type === "reasoning")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("")
|
||||||
|
const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
reasoning_content: undefined,
|
||||||
|
reasoning_text: undefined,
|
||||||
|
reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
|
||||||
|
reasoning_details: reasoningDetails,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
messages,
|
||||||
|
...bodyOptions(request.providerOptions?.openrouter),
|
||||||
|
} as OpenRouterBody
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
stream: OpenAIChat.protocol.stream,
|
stream: OpenAIChat.protocol.stream,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||||
import type { RouteDefaultsInput } from "../route/client"
|
import type { RouteDefaultsInput } from "../route/client"
|
||||||
import { ProviderID, type ModelID } from "../schema"
|
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
|
import { XAIImages } from "../protocols/xai-images"
|
||||||
|
|
||||||
export const id = ProviderID.make("xai")
|
export const id = ProviderID.make("xai")
|
||||||
|
|
||||||
|
|
@ -12,6 +13,8 @@ export type ModelOptions = RouteDefaultsInput &
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { XAIImageOptions } from "../protocols/xai-images"
|
||||||
|
|
||||||
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
||||||
|
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
||||||
|
|
@ -41,11 +44,20 @@ export const configure = (input: ModelOptions = {}) => {
|
||||||
const chatRoute = configuredChatRoute(input)
|
const chatRoute = configuredChatRoute(input)
|
||||||
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
|
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
|
||||||
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
|
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
|
||||||
|
const image = (modelID: string | ModelID) =>
|
||||||
|
XAIImages.model({
|
||||||
|
id: modelID,
|
||||||
|
auth: auth(input),
|
||||||
|
baseURL: input.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
|
||||||
|
headers: input.headers,
|
||||||
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: responses,
|
model: responses,
|
||||||
responses,
|
responses,
|
||||||
chat,
|
chat,
|
||||||
|
image,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,3 +66,4 @@ export const provider = configure()
|
||||||
export const model = provider.model
|
export const model = provider.model
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
|
export const image = provider.image
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Config, Effect, Redacted } from "effect"
|
import { Config, Effect, Redacted } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
|
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema"
|
||||||
|
|
||||||
export class MissingCredentialError extends Error {
|
export class MissingCredentialError extends Error {
|
||||||
readonly _tag = "MissingCredentialError"
|
readonly _tag = "MissingCredentialError"
|
||||||
|
|
@ -15,7 +15,7 @@ export type AuthError = CredentialError | LLMError
|
||||||
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
||||||
|
|
||||||
export interface AuthInput {
|
export interface AuthInput {
|
||||||
readonly request: LLMRequest
|
readonly request: { readonly http?: HttpOptions }
|
||||||
readonly method: "POST" | "GET"
|
readonly method: "POST" | "GET"
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly body: string
|
readonly body: string
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ export const ToolInputStart = Schema.Struct({
|
||||||
type: Schema.tag("tool-input-start"),
|
type: Schema.tag("tool-input-start"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
|
providerExecuted: Schema.optional(Schema.Boolean),
|
||||||
providerMetadata: Schema.optional(ProviderMetadata),
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||||
|
|
@ -149,6 +150,15 @@ export const ToolInputEnd = Schema.Struct({
|
||||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||||
|
|
||||||
|
/** A local tool call whose final input could not be decoded. */
|
||||||
|
export const ToolInputError = Schema.Struct({
|
||||||
|
type: Schema.tag("tool-input-error"),
|
||||||
|
id: ToolCallID,
|
||||||
|
name: Schema.String,
|
||||||
|
raw: Schema.String,
|
||||||
|
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||||
|
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
||||||
|
|
||||||
export const ToolCall = Schema.Struct({
|
export const ToolCall = Schema.Struct({
|
||||||
type: Schema.tag("tool-call"),
|
type: Schema.tag("tool-call"),
|
||||||
id: ToolCallID,
|
id: ToolCallID,
|
||||||
|
|
@ -216,6 +226,7 @@ const llmEventTagged = Schema.Union([
|
||||||
ToolInputStart,
|
ToolInputStart,
|
||||||
ToolInputDelta,
|
ToolInputDelta,
|
||||||
ToolInputEnd,
|
ToolInputEnd,
|
||||||
|
ToolInputError,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
ToolResult,
|
ToolResult,
|
||||||
ToolError,
|
ToolError,
|
||||||
|
|
@ -253,6 +264,8 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||||
|
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
|
||||||
|
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||||
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
||||||
ToolResult.make({
|
ToolResult.make({
|
||||||
|
|
@ -283,6 +296,7 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||||
|
toolInputError: llmEventTagged.guards["tool-input-error"],
|
||||||
toolCall: llmEventTagged.guards["tool-call"],
|
toolCall: llmEventTagged.guards["tool-call"],
|
||||||
toolResult: llmEventTagged.guards["tool-result"],
|
toolResult: llmEventTagged.guards["tool-result"],
|
||||||
toolError: llmEventTagged.guards["tool-error"],
|
toolError: llmEventTagged.guards["tool-error"],
|
||||||
|
|
@ -548,6 +562,10 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
||||||
return reduceToolInputDelta(next, event)
|
return reduceToolInputDelta(next, event)
|
||||||
case "tool-input-end":
|
case "tool-input-end":
|
||||||
return reduceToolInputEnd(next, event)
|
return reduceToolInputEnd(next, event)
|
||||||
|
case "tool-input-error": {
|
||||||
|
const { [event.id]: _finished, ...toolInputs } = next.toolInputs
|
||||||
|
return { ...next, toolInputs }
|
||||||
|
}
|
||||||
case "tool-call":
|
case "tool-call":
|
||||||
return reduceToolCall(next, event)
|
return reduceToolCall(next, event)
|
||||||
case "tool-result":
|
case "tool-result":
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Config } from "effect"
|
import { Config } from "effect"
|
||||||
import type { Auth } from "../src/route/auth"
|
import { Auth } from "../src/route"
|
||||||
import type { ModelFactory } from "../src/route/auth-options"
|
import type { ModelFactory } from "../src/route/auth-options"
|
||||||
import { Auth as RuntimeAuth } from "../src/route/auth"
|
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||||
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
|
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
|
||||||
import * as Anthropic from "../src/providers/anthropic"
|
import * as Anthropic from "../src/providers/anthropic"
|
||||||
|
|
@ -28,7 +27,7 @@ type Model = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
declare const auth: Auth
|
declare const auth: Auth.Definition
|
||||||
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
|
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
|
||||||
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
|
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
|
||||||
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
||||||
|
|
@ -76,9 +75,9 @@ OpenAI.responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({}).responses("gpt-4.1-mini")
|
OpenAI.configure({}).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).responses("gpt-4.1-mini")
|
OpenAI.configure({ auth: Auth.bearer("oauth-token") }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({
|
OpenAI.configure({
|
||||||
auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }),
|
auth: Auth.headers({ authorization: "Bearer gateway" }),
|
||||||
baseURL: "https://gateway.example.com/v1",
|
baseURL: "https://gateway.example.com/v1",
|
||||||
}).responses("gpt-4.1-mini")
|
}).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({
|
OpenAI.configure({
|
||||||
|
|
@ -102,40 +101,40 @@ OpenAI.configure({ generation: { maxTokens: "many" } })
|
||||||
OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
|
OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
||||||
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
|
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
||||||
|
|
||||||
OpenAI.chat("gpt-4.1-mini")
|
OpenAI.chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini")
|
OpenAI.configure({ auth: Auth.bearer("oauth-token") }).chat("gpt-4.1-mini")
|
||||||
|
|
||||||
// @ts-expect-error OpenAI chat selectors only accept model ids.
|
// @ts-expect-error OpenAI chat selectors only accept model ids.
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
|
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
|
||||||
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
|
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
||||||
|
|
||||||
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
|
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
|
||||||
Azure.configure()
|
Azure.configure()
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
|
||||||
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
|
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
|
||||||
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
|
Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
|
||||||
|
|
||||||
// @ts-expect-error Azure model selectors only accept deployment ids.
|
// @ts-expect-error Azure model selectors only accept deployment ids.
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
|
||||||
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
|
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
|
||||||
|
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
|
||||||
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
|
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
|
||||||
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
|
Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
|
||||||
|
|
||||||
// @ts-expect-error Azure chat model selectors only accept deployment ids.
|
// @ts-expect-error Azure chat model selectors only accept deployment ids.
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
|
||||||
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
|
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
|
||||||
|
|
||||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
|
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
|
||||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||||
|
|
@ -165,7 +164,7 @@ Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
|
||||||
|
|
||||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
|
||||||
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
|
||||||
GoogleVertex.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
|
||||||
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
|
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
|
||||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
||||||
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
||||||
|
|
@ -174,7 +173,7 @@ GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", proj
|
||||||
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||||
|
|
||||||
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
||||||
GoogleVertexChat.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||||
"deepseek-ai/deepseek-v3.2-maas",
|
"deepseek-ai/deepseek-v3.2-maas",
|
||||||
)
|
)
|
||||||
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
||||||
|
|
@ -187,12 +186,12 @@ GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).
|
||||||
GoogleVertexChat.configure({
|
GoogleVertexChat.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Chat config accepts only one auth source.
|
// @ts-expect-error Vertex Chat config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
|
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
|
||||||
GoogleVertexResponses.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||||
"xai/grok-4.20-reasoning",
|
"xai/grok-4.20-reasoning",
|
||||||
)
|
)
|
||||||
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
||||||
|
|
@ -205,16 +204,14 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
|
||||||
GoogleVertexResponses.configure({
|
GoogleVertexResponses.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Responses config accepts only one auth source.
|
// @ts-expect-error Vertex Responses config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
||||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||||
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||||
GoogleVertexMessages.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
|
||||||
"claude-sonnet-4-6",
|
|
||||||
)
|
|
||||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
// @ts-expect-error Vertex Messages model selectors only accept model ids.
|
// @ts-expect-error Vertex Messages model selectors only accept model ids.
|
||||||
|
|
@ -223,7 +220,7 @@ GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project"
|
||||||
GoogleVertexMessages.configure({
|
GoogleVertexMessages.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Messages config accepts only one auth source.
|
// @ts-expect-error Vertex Messages config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "minimax",
|
||||||
|
"protocol": "anthropic-messages",
|
||||||
|
"route": "anthropic-messages",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "MiniMax-M3",
|
||||||
|
"tags": [
|
||||||
|
"prefix:anthropic-compatible-messages",
|
||||||
|
"provider:minimax",
|
||||||
|
"protocol:anthropic-messages",
|
||||||
|
"text",
|
||||||
|
"golden"
|
||||||
|
],
|
||||||
|
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-text",
|
||||||
|
"recordedAt": "2026-07-18T03:42:22.893Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"1a0b363d0882af316faebcec4d4855a8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":53,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":53,\"output_tokens\":2,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "minimax",
|
||||||
|
"protocol": "anthropic-messages",
|
||||||
|
"route": "anthropic-messages",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "MiniMax-M3",
|
||||||
|
"tags": [
|
||||||
|
"prefix:anthropic-compatible-messages",
|
||||||
|
"provider:minimax",
|
||||||
|
"protocol:anthropic-messages",
|
||||||
|
"tool",
|
||||||
|
"tool-call",
|
||||||
|
"golden"
|
||||||
|
],
|
||||||
|
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-call",
|
||||||
|
"recordedAt": "2026-07-18T03:42:23.876Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6731ecc323233459d1792df9a733dd98\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":404,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_vkxtif4epmvm_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":290,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"provider": "minimax",
|
||||||
|
"protocol": "anthropic-messages",
|
||||||
|
"route": "anthropic-messages",
|
||||||
|
"transport": "http",
|
||||||
|
"model": "MiniMax-M3",
|
||||||
|
"tags": [
|
||||||
|
"prefix:anthropic-compatible-messages",
|
||||||
|
"provider:minimax",
|
||||||
|
"protocol:anthropic-messages",
|
||||||
|
"tool",
|
||||||
|
"tool-loop",
|
||||||
|
"golden"
|
||||||
|
],
|
||||||
|
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-loop",
|
||||||
|
"recordedAt": "2026-07-18T03:42:25.248Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"3807fa12f9ecb9357df511e099da6da0\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":417,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":303,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||||
|
"headers": {
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_function_yr64rwmre4gr_1\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream; charset=utf-8"
|
||||||
|
},
|
||||||
|
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"92f8a1e86f29946eb2699d40a088fc08\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":41,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":41,\"output_tokens\":4,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
32
packages/ai/test/fixtures/recordings/openai-images/generates-an-image.json
vendored
Normal file
32
packages/ai/test/fixtures/recordings/openai-images/generates-an-image.json
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
55
packages/ai/test/fixtures/recordings/openrouter-reasoning-tool-loop.json
vendored
Normal file
55
packages/ai/test/fixtures/recordings/openrouter-reasoning-tool-loop.json
vendored
Normal file
File diff suppressed because one or more lines are too long
34
packages/ai/test/fixtures/recordings/openrouter-reasoning.json
vendored
Normal file
34
packages/ai/test/fixtures/recordings/openrouter-reasoning.json
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"model": "anthropic/claude-sonnet-4.6",
|
||||||
|
"tags": [
|
||||||
|
"prefix:openai-compatible-chat",
|
||||||
|
"provider:openrouter",
|
||||||
|
"protocol:openai-chat",
|
||||||
|
"reasoning"
|
||||||
|
],
|
||||||
|
"name": "openrouter-reasoning",
|
||||||
|
"recordedAt": "2026-07-18T11:28:39.267Z"
|
||||||
|
},
|
||||||
|
"interactions": [
|
||||||
|
{
|
||||||
|
"transport": "http",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
|
||||||
|
},
|
||||||
|
"response": {
|
||||||
|
"status": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "text/event-stream"
|
||||||
|
},
|
||||||
|
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
55
packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning-tool-loop.json
vendored
Normal file
55
packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning-tool-loop.json
vendored
Normal file
File diff suppressed because one or more lines are too long
34
packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning.json
vendored
Normal file
34
packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning.json
vendored
Normal file
File diff suppressed because one or more lines are too long
32
packages/ai/test/fixtures/recordings/xai-images/generates-an-image.json
vendored
Normal file
32
packages/ai/test/fixtures/recordings/xai-images/generates-an-image.json
vendored
Normal file
File diff suppressed because one or more lines are too long
127
packages/ai/test/image.test.ts
Normal file
127
packages/ai/test/image.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { Image, ImageClient } from "../src"
|
||||||
|
import { OpenAI } from "../src/providers"
|
||||||
|
import { it } from "./lib/effect"
|
||||||
|
import { dynamicResponse } from "./lib/http"
|
||||||
|
|
||||||
|
describe("Image", () => {
|
||||||
|
it.effect("generates images through the OpenAI Images API", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: OpenAI.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://api.openai.test/v1",
|
||||||
|
queryParams: { "api-version": "v1" },
|
||||||
|
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
|
||||||
|
}).image("gpt-image-2"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
size: "2048x2048",
|
||||||
|
quality: "future-quality",
|
||||||
|
outputFormat: "jpeg",
|
||||||
|
output_format: "avif",
|
||||||
|
outputCompression: 30,
|
||||||
|
output_compression: 40,
|
||||||
|
background: "opaque",
|
||||||
|
native_default: true,
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
|
||||||
|
headers: { "x-request": "yes" },
|
||||||
|
query: { trace: "1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(2)
|
||||||
|
expect(response.image?.mediaType).toBe("image/webp")
|
||||||
|
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
|
||||||
|
expect(response.image?.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
|
||||||
|
expect(response.usage?.totalTokens).toBe(12)
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe("https://api.openai.test/v1/images/generations?api-version=v1&trace=1")
|
||||||
|
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||||
|
expect(request.headers.get("x-default")).toBe("yes")
|
||||||
|
expect(request.headers.get("x-request")).toBe("yes")
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "gpt-image-2",
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
n: 2,
|
||||||
|
size: "2048x2048",
|
||||||
|
quality: "future-quality",
|
||||||
|
background: "opaque",
|
||||||
|
output_format: "webp",
|
||||||
|
output_compression: 50,
|
||||||
|
native_default: true,
|
||||||
|
future_option: "http",
|
||||||
|
deployment: "test",
|
||||||
|
request_metadata: "value",
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
|
||||||
|
output_format: "webp",
|
||||||
|
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves native snake_case and unknown request options", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: OpenAI.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://api.openai.test/v1",
|
||||||
|
}).image("future-image-model"),
|
||||||
|
prompt: "A lighthouse in fog",
|
||||||
|
options: {
|
||||||
|
outputFormat: "jpeg",
|
||||||
|
output_format: "avif",
|
||||||
|
outputCompression: 30,
|
||||||
|
output_compression: 40,
|
||||||
|
provider_future_option: { enabled: true },
|
||||||
|
},
|
||||||
|
}).pipe(
|
||||||
|
Effect.tap((response) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
expect(response.image?.mediaType).toBe("image/avif")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-image-model",
|
||||||
|
prompt: "A lighthouse in fog",
|
||||||
|
output_format: "avif",
|
||||||
|
output_compression: 40,
|
||||||
|
provider_future_option: { enabled: true },
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
95
packages/ai/test/image.types.ts
Normal file
95
packages/ai/test/image.types.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ImageModel,
|
||||||
|
type ImageModelOptions,
|
||||||
|
type ImageOptions,
|
||||||
|
type ImageRequestFor,
|
||||||
|
type ImageRoute,
|
||||||
|
} from "../src"
|
||||||
|
import { OpenAI, XAI } from "../src/providers"
|
||||||
|
|
||||||
|
type GoogleLikeOptions = {
|
||||||
|
readonly aspectRatio?: "1:1" | "16:9"
|
||||||
|
readonly imageSize?: "1K" | "2K"
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
declare const route: ImageRoute<GoogleLikeOptions>
|
||||||
|
const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
|
||||||
|
// @ts-expect-error Extracted model options retain known provider fields.
|
||||||
|
const invalidGoogleOptions: ImageModelOptions<typeof google> = { aspectRatio: "wide" }
|
||||||
|
void invalidGoogleOptions
|
||||||
|
|
||||||
|
Image.generate({
|
||||||
|
model: google,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const openai = OpenAI.image("gpt-image-2")
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
OpenAI.configure({ image: { options: { quality: "medium" } } })
|
||||||
|
const futureOpenAIOptions: ImageModelOptions<typeof openai> = { quality: "future-quality" }
|
||||||
|
void futureOpenAIOptions
|
||||||
|
Image.generate({
|
||||||
|
model: openai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { quality: "hd", outputFormat: "webp", size: "2048x2048", future_option: true },
|
||||||
|
})
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "future-quality", size: "256x256" } })
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { size: "1792x1024" } })
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { native_future_option: true } })
|
||||||
|
// @ts-expect-error Known OpenAI string options retain their value kind.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: 1 } })
|
||||||
|
// @ts-expect-error Known OpenAI numeric options retain their value kind.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { outputCompression: "80" } })
|
||||||
|
OpenAI.imageGeneration({ action: "future-action", quality: "future-quality", size: "2048x2048" })
|
||||||
|
// @ts-expect-error Hosted image generation numeric options retain their value kind.
|
||||||
|
OpenAI.imageGeneration({ partialImages: "2" })
|
||||||
|
// @ts-expect-error Known Google-like options are inferred from the selected model.
|
||||||
|
Image.generate({ model: google, prompt: "A lighthouse", options: { aspectRatio: "wide" } })
|
||||||
|
|
||||||
|
const xai = XAI.configure({ apiKey: "test" }).image("any-model-id")
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
XAI.configure({ image: { options: { resolution: "1k" } } })
|
||||||
|
Image.generate({
|
||||||
|
model: xai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
aspectRatio: "future-ratio",
|
||||||
|
resolution: "future-resolution",
|
||||||
|
responseFormat: "future-format",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Image.generate({
|
||||||
|
model: xai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspect_ratio: "16:9", response_format: "b64_json", native_future_option: true },
|
||||||
|
})
|
||||||
|
// @ts-expect-error Known xAI numeric options retain their value kind.
|
||||||
|
Image.generate({ model: xai, prompt: "A lighthouse", options: { n: "2" } })
|
||||||
|
// @ts-expect-error Known xAI string options retain their value kind.
|
||||||
|
Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } })
|
||||||
|
|
||||||
|
declare const generic: ImageModel<ImageOptions>
|
||||||
|
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
|
||||||
|
|
||||||
|
const request = Image.request({
|
||||||
|
model: google,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspectRatio: "1:1", futureOption: true },
|
||||||
|
})
|
||||||
|
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
|
||||||
|
void typedRequest
|
||||||
|
|
||||||
|
// @ts-expect-error Image requests no longer expose a common count option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common size option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", size: { width: 1024, height: 1024 } })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common aspectRatio option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", aspectRatio: "16:9" })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common seed option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", seed: 1 })
|
||||||
|
// @ts-expect-error Image requests do not expose metadata.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } })
|
||||||
|
|
@ -3,8 +3,30 @@ import { isContextOverflow } from "../src"
|
||||||
import { classifyProviderFailure } from "../src/provider-error"
|
import { classifyProviderFailure } from "../src/provider-error"
|
||||||
|
|
||||||
describe("provider error classification", () => {
|
describe("provider error classification", () => {
|
||||||
test("classifies Z.AI GLM token limit messages as context overflow", () => {
|
test("classifies provider token limit messages as context overflow", () => {
|
||||||
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
|
const messages = [
|
||||||
|
"tokens in request more than max tokens allowed",
|
||||||
|
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||||
|
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||||
|
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||||
|
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||||
|
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
|
||||||
|
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
||||||
|
"Too many tokens",
|
||||||
|
"Token limit exceeded",
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(messages.every(isContextOverflow)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not classify rate limits as context overflow", () => {
|
||||||
|
const messages = [
|
||||||
|
"Throttling error: Too many tokens, please wait before trying again.",
|
||||||
|
"Rate limit exceeded, please retry after 30 seconds.",
|
||||||
|
"Too many requests. Please slow down.",
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(messages.some(isContextOverflow)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("classifies V1 plain-text rate limit fallbacks", () => {
|
test("classifies V1 plain-text rate limit fallbacks", () => {
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,30 @@ describe("Anthropic Messages route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("keeps malformed server tool input terminal", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||||
|
{
|
||||||
|
type: "content_block_start",
|
||||||
|
index: 0,
|
||||||
|
content_block: { type: "server_tool_use", id: "call_1", name: "web_search" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "content_block_delta",
|
||||||
|
index: 0,
|
||||||
|
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
|
||||||
|
},
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(LLMError)
|
||||||
|
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails with a typed provider error for stream error frames", () =>
|
it.effect("fails with a typed provider error for stream error frames", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.generate(request).pipe(
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,32 @@ describe("Bedrock Converse route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("emits malformed tool input as an unexecuted tool error", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = eventStreamBody(
|
||||||
|
["messageStart", { role: "assistant" }],
|
||||||
|
[
|
||||||
|
"contentBlockStart",
|
||||||
|
{
|
||||||
|
contentBlockIndex: 0,
|
||||||
|
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
|
||||||
|
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||||
|
["messageStop", { stopReason: "end_turn" }],
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||||
|
|
||||||
|
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
|
||||||
|
id: "tool_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
})
|
||||||
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("decodes reasoning deltas", () =>
|
it.effect("decodes reasoning deltas", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = eventStreamBody(
|
const body = eventStreamBody(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Schema } from "effect"
|
import { ConfigProvider, Effect, Schema } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM } from "../../src"
|
import { LLM, LLMEvent } from "../../src"
|
||||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
|
|
@ -83,6 +83,59 @@ describe("Cloudflare", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves reasoning details for AI Gateway continuation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const model = CloudflareAIGateway.configure({
|
||||||
|
accountId: "test-account",
|
||||||
|
gatewayId: "test-gateway",
|
||||||
|
apiKey: "test-token",
|
||||||
|
}).model("anthropic/claude-sonnet-4.6")
|
||||||
|
const details = [
|
||||||
|
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
]
|
||||||
|
const merged = [
|
||||||
|
{
|
||||||
|
type: "reasoning.text",
|
||||||
|
text: "Thinking",
|
||||||
|
signature: "signed",
|
||||||
|
format: "anthropic-claude-v1",
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.succeed(
|
||||||
|
input.respond(
|
||||||
|
sseEvents(
|
||||||
|
deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
|
||||||
|
deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
|
||||||
|
deltaChunk({ reasoning_details: [details[2]] }),
|
||||||
|
deltaChunk({ content: "Hello" }),
|
||||||
|
deltaChunk({}, "stop"),
|
||||||
|
),
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("Thinking")
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||||
|
})
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
|
||||||
|
expect(replay.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
|
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as Anthropic from "../../src/providers/anthropic"
|
import * as Anthropic from "../../src/providers/anthropic"
|
||||||
|
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible"
|
||||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
||||||
import * as Google from "../../src/providers/google"
|
import * as Google from "../../src/providers/google"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
|
|
@ -17,6 +18,11 @@ const anthropic = Anthropic.configure({
|
||||||
})
|
})
|
||||||
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
|
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
|
||||||
const anthropicOpus = anthropic.model("claude-opus-4-7")
|
const anthropicOpus = anthropic.model("claude-opus-4-7")
|
||||||
|
const minimax = AnthropicCompatible.configure({
|
||||||
|
apiKey: process.env.MINIMAX_API_KEY ?? "fixture",
|
||||||
|
baseURL: "https://api.minimax.io/anthropic/v1",
|
||||||
|
provider: "minimax",
|
||||||
|
}).model("MiniMax-M3")
|
||||||
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
|
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
|
||||||
const gemini = google.model("gemini-2.5-flash")
|
const gemini = google.model("gemini-2.5-flash")
|
||||||
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
|
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
|
||||||
|
|
@ -108,6 +114,15 @@ describeRecordedGoldenScenarios([
|
||||||
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
|
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "MiniMax M3 Anthropic-compatible",
|
||||||
|
prefix: "anthropic-compatible-messages",
|
||||||
|
protocol: "anthropic-messages",
|
||||||
|
model: minimax,
|
||||||
|
requires: ["MINIMAX_API_KEY"],
|
||||||
|
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
|
||||||
|
scenarios: ["text", "tool-call", "tool-loop"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Gemini 2.5 Flash",
|
name: "Gemini 2.5 Flash",
|
||||||
prefix: "gemini",
|
prefix: "gemini",
|
||||||
|
|
|
||||||
142
packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts
Normal file
142
packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { LLM, LLMEvent, LLMResponse } from "../../src"
|
||||||
|
import { OpenAIChat } from "../../src/protocols/openai-chat"
|
||||||
|
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||||
|
import * as OpenRouter from "../../src/providers/openrouter"
|
||||||
|
import { LLMClient } from "../../src/route"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
name: "OpenRouter",
|
||||||
|
model: OpenRouter.configure({
|
||||||
|
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
|
||||||
|
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
|
||||||
|
}).model("anthropic/claude-sonnet-4.6"),
|
||||||
|
requires: ["OPENROUTER_API_KEY"],
|
||||||
|
cassette: "openrouter-reasoning",
|
||||||
|
structured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Vercel AI Gateway",
|
||||||
|
model: OpenAICompatible.configure({
|
||||||
|
provider: "vercel-ai-gateway",
|
||||||
|
baseURL: "https://ai-gateway.vercel.sh/v1",
|
||||||
|
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
|
||||||
|
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
|
||||||
|
}).model("anthropic/claude-sonnet-4.6"),
|
||||||
|
requires: ["AI_GATEWAY_API_KEY"],
|
||||||
|
cassette: "vercel-ai-gateway-reasoning",
|
||||||
|
structured: true,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const item of cases) {
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "openai-compatible-chat",
|
||||||
|
provider: item.model.provider,
|
||||||
|
protocol: "openai-chat",
|
||||||
|
requires: item.requires,
|
||||||
|
tags: ["reasoning"],
|
||||||
|
metadata: { model: item.model.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`${item.name} reasoning recorded`, () => {
|
||||||
|
recorded.effect.with(
|
||||||
|
"streams scalar reasoning",
|
||||||
|
{ cassette: item.cassette },
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: item.model,
|
||||||
|
system: "Think through the arithmetic, then reply with only the final integer.",
|
||||||
|
prompt: "What is 173 multiplied by 219?",
|
||||||
|
generation: { maxTokens: 1536, temperature: 0 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||||
|
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||||
|
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||||
|
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
|
||||||
|
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||||
|
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
|
||||||
|
if (!item.structured) return
|
||||||
|
const details = metadata?.openai?.reasoningDetails
|
||||||
|
if (!Array.isArray(details)) return
|
||||||
|
expect(
|
||||||
|
details.some(
|
||||||
|
(detail) =>
|
||||||
|
typeof detail === "object" &&
|
||||||
|
detail !== null &&
|
||||||
|
"signature" in detail &&
|
||||||
|
typeof detail.signature === "string" &&
|
||||||
|
detail.signature.length > 0,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({ model: item.model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(replay.body.messages).toMatchObject([
|
||||||
|
{ role: "assistant", content: response.text, reasoning: response.reasoning },
|
||||||
|
])
|
||||||
|
const replayDetails =
|
||||||
|
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
|
||||||
|
expect(Array.isArray(replayDetails)).toBe(true)
|
||||||
|
if (!Array.isArray(replayDetails)) return
|
||||||
|
expect(replayDetails).toEqual(details)
|
||||||
|
expect(replayDetails).toHaveLength(1)
|
||||||
|
expect(replayDetails[0]).toMatchObject({
|
||||||
|
type: "reasoning.text",
|
||||||
|
text: response.reasoning,
|
||||||
|
signature: expect.any(String),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
30_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
recorded.effect.with(
|
||||||
|
"continues signed reasoning through a tool loop",
|
||||||
|
{ cassette: `${item.cassette}-tool-loop`, tags: ["continuation", "tool", "tool-loop"] },
|
||||||
|
() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = yield* runWeatherToolLoop(
|
||||||
|
goldenWeatherToolLoopRequest({
|
||||||
|
id: `${item.cassette}-tool-loop`,
|
||||||
|
model: item.model,
|
||||||
|
maxTokens: 1536,
|
||||||
|
temperature: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expectWeatherToolLoop(events)
|
||||||
|
expect(
|
||||||
|
LLMResponse.text({
|
||||||
|
events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1),
|
||||||
|
}).trim(),
|
||||||
|
).toMatch(/^Paris is sunny\.?$/)
|
||||||
|
const details = events
|
||||||
|
.filter(LLMEvent.is.reasoningEnd)
|
||||||
|
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
|
||||||
|
.find(Array.isArray)
|
||||||
|
expect(Array.isArray(details)).toBe(item.structured)
|
||||||
|
if (!item.structured || !Array.isArray(details)) return
|
||||||
|
expect(
|
||||||
|
details.some(
|
||||||
|
(detail) =>
|
||||||
|
typeof detail === "object" &&
|
||||||
|
detail !== null &&
|
||||||
|
"signature" in detail &&
|
||||||
|
typeof detail.signature === "string" &&
|
||||||
|
detail.signature.length > 0,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
}),
|
||||||
|
60_000,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -540,28 +540,401 @@ describe("OpenAI Chat route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
|
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
||||||
{ choices: [{ delta: { reasoning_content: "thinking" } }] },
|
for (const field of fields) {
|
||||||
{ choices: [{ delta: { content: "Hello" } }] },
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { [field]: "thinking" } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("thinking")
|
||||||
|
expect(response.text).toBe("Hello")
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: field },
|
||||||
|
})
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({ model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [
|
||||||
|
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
|
||||||
|
]
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.updateRequest(request, {
|
||||||
|
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
|
||||||
|
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
|
||||||
|
{
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
expect(response.reasoning).toBe("thinking")
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||||
|
})
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({ model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(replay.body.messages).toEqual([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
reasoning: "thinking",
|
||||||
|
reasoning_details: details,
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
id: "call_1",
|
||||||
|
type: "function",
|
||||||
|
function: { name: "lookup", arguments: '{"query":"weather"}' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [
|
||||||
|
{ type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
|
||||||
|
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
|
||||||
|
]
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning_details: [details[0]] } }] },
|
||||||
|
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
expect(response.reasoning).toBe("thinking")
|
expect(response.reasoning).toBe("thinking")
|
||||||
expect(response.text).toBe("Hello")
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
expect(response.events).toMatchObject([
|
openai: { reasoningDetails: details },
|
||||||
{ type: "step-start", index: 0 },
|
})
|
||||||
{ type: "reasoning-start", id: "reasoning-0" },
|
|
||||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
{ type: "reasoning-end", id: "reasoning-0" },
|
LLM.request({ model, messages: [response.message] }),
|
||||||
{ type: "text-start", id: "text-0" },
|
)
|
||||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
|
||||||
{ type: "text-end", id: "text-0" },
|
}),
|
||||||
{ type: "step-finish", index: 0, reason: "stop" },
|
)
|
||||||
{ type: "finish", reason: "stop" },
|
|
||||||
|
it.effect("preserves unknown reasoning details while using scalar display text", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("thinking")
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||||
|
})
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({ model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(replay.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("uses scalar display text for signature-only reasoning details", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("thinking")
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("ignores scalar reasoning after content starts", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning_details: details } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: { reasoning: "scalar" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("detail")
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningDetails: details },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves an explicitly empty reasoning details array", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning_details: [] } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("")
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningDetails: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({ model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("attaches signature-only details that arrive after content", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [
|
||||||
|
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
]
|
||||||
|
const merged = [
|
||||||
|
{
|
||||||
|
type: "reasoning.text",
|
||||||
|
text: "thinking",
|
||||||
|
signature: "signed",
|
||||||
|
format: "anthropic-claude-v1",
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
|
||||||
|
{ choices: [{ delta: { content: "Hello" } }] },
|
||||||
|
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("thinking")
|
||||||
|
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||||
|
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||||
|
})
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
|
||||||
|
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||||
|
})
|
||||||
|
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
|
||||||
|
response.events.findIndex(LLMEvent.is.textStart),
|
||||||
|
)
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({ model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(replay.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves metadata-only reasoning when the stream ends", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning_details: details } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
|
||||||
|
])
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||||
|
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||||
|
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({ model, messages: [response.message] }),
|
||||||
|
)
|
||||||
|
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("flushes details-only display reasoning when the stream ends", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ choices: [{ delta: { reasoning_details: details } }] },
|
||||||
|
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.reasoning).toBe("summary")
|
||||||
|
expect(response.message.content).toEqual([
|
||||||
|
{ type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("replays details from multiple reasoning parts in order", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
|
||||||
|
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "first",
|
||||||
|
providerMetadata: { openai: { reasoningDetails: [first] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "second",
|
||||||
|
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(replay.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "A",
|
||||||
|
providerMetadata: { openai: { reasoningDetails: [detail] } },
|
||||||
|
},
|
||||||
|
{ type: "reasoning", text: "B" },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(replay.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("replays native scalar reasoning alongside native details", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
|
||||||
|
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.make({
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "reasoning", text: "thinking" }],
|
||||||
|
native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(replay.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
|
||||||
])
|
])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
@ -602,6 +975,67 @@ describe("OpenAI Chat route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("ignores empty identity fields on later tool call deltas", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
deltaChunk({
|
||||||
|
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
|
||||||
|
}),
|
||||||
|
deltaChunk({
|
||||||
|
tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
|
||||||
|
}),
|
||||||
|
deltaChunk({}, "tool_calls"),
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.updateRequest(request, {
|
||||||
|
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("buffers tool call deltas until the function name arrives", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
deltaChunk({
|
||||||
|
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
|
||||||
|
}),
|
||||||
|
deltaChunk({
|
||||||
|
tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
|
||||||
|
}),
|
||||||
|
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
|
||||||
|
deltaChunk({}, "tool_calls"),
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.updateRequest(request, {
|
||||||
|
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("fails when a buffered tool call never receives a function name", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
deltaChunk({
|
||||||
|
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
|
||||||
|
}),
|
||||||
|
deltaChunk({}, "tool_calls"),
|
||||||
|
)
|
||||||
|
const error = yield* LLMClient.generate(
|
||||||
|
LLM.updateRequest(request, {
|
||||||
|
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||||
|
|
||||||
|
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
|
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|
|
||||||
33
packages/ai/test/provider/openai-images.recorded.test.ts
Normal file
33
packages/ai/test/provider/openai-images.recorded.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Image } from "../../src"
|
||||||
|
import { OpenAI } from "../../src/providers"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = OpenAI.configure({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||||
|
}).image("gpt-image-1-mini")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "openai-images",
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "openai-images",
|
||||||
|
requires: ["OPENAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("OpenAI Images recorded", () => {
|
||||||
|
recorded.effect("generates an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "A simple flat black circle centered on a plain white background.",
|
||||||
|
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { LLM, LLMEvent, Message } from "../../src"
|
||||||
|
import { OpenAI } from "../../src/providers"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const openai = OpenAI.configure({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||||
|
})
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "openai-responses-images",
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
requires: ["OPENAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("OpenAI Responses image generation recorded", () => {
|
||||||
|
recorded.effect("generates and edits an image with the hosted tool", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const initial = Message.user("Generate a simple flat black triangle centered on a plain white background.")
|
||||||
|
const tools = [
|
||||||
|
OpenAI.imageGeneration({
|
||||||
|
action: "auto",
|
||||||
|
quality: "low",
|
||||||
|
size: "1024x1024",
|
||||||
|
outputFormat: "jpeg",
|
||||||
|
outputCompression: 10,
|
||||||
|
partialImages: 0,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const response = yield* LLM.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: openai.responses("gpt-5-mini"),
|
||||||
|
messages: [initial],
|
||||||
|
tools,
|
||||||
|
toolChoice: "image_generation",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = response.events.find(LLMEvent.is.toolResult)
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result?.providerExecuted).toBe(true)
|
||||||
|
expect(result?.result.type).toBe("content")
|
||||||
|
if (result?.result.type !== "content") return
|
||||||
|
expect(result.result.value).toHaveLength(1)
|
||||||
|
expect(result.result.value[0]?.type).toBe("file")
|
||||||
|
if (result.result.value[0]?.type !== "file") return
|
||||||
|
expect(result.result.value[0].mime).toBe("image/jpeg")
|
||||||
|
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
|
||||||
|
|
||||||
|
const edited = yield* LLM.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: openai.responses("gpt-5-mini"),
|
||||||
|
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
|
||||||
|
tools,
|
||||||
|
toolChoice: "image_generation",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const editedResult = edited.events.find(LLMEvent.is.toolResult)
|
||||||
|
expect(editedResult?.result.type).toBe("content")
|
||||||
|
if (editedResult?.result.type !== "content") return
|
||||||
|
expect(editedResult.result.value[0]?.type).toBe("file")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
|
||||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||||
import * as Azure from "../../src/providers/azure"
|
import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
|
|
@ -58,6 +58,39 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("lowers the hosted OpenAI image generation tool", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Show me a rooftop garden.",
|
||||||
|
tools: [OpenAI.imageGeneration({ action: "generate", quality: "high", size: "1024x1024" })],
|
||||||
|
toolChoice: "image_generation",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.tools).toEqual([
|
||||||
|
{ type: "image_generation", action: "generate", quality: "high", size: "1024x1024" },
|
||||||
|
])
|
||||||
|
expect(prepared.body.tool_choice).toEqual({ type: "image_generation" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects invalid hosted image generation options locally", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.prepare(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
prompt: "Show me a rooftop garden.",
|
||||||
|
tools: [OpenAI.imageGeneration({ outputCompression: -1, partialImages: 4, size: "bogus" })],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(error.reason._tag).toBe("InvalidRequest")
|
||||||
|
expect(error.message).toContain("image generation tool options are invalid")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("lowers semantic service tier options", () =>
|
it.effect("lowers semantic service tier options", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
|
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
|
||||||
|
|
@ -1103,6 +1136,48 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("continues stateless hosted image generation with the generated image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const imageTool = OpenAI.imageGeneration({ action: "edit" })
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.user("Generate a black triangle."),
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({
|
||||||
|
id: "ig_1",
|
||||||
|
name: "image_generation",
|
||||||
|
input: {},
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { itemId: "ig_1" } },
|
||||||
|
}),
|
||||||
|
ToolResultPart.make({
|
||||||
|
id: "ig_1",
|
||||||
|
name: "image_generation",
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
|
||||||
|
},
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { itemId: "ig_1" } },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Message.user("Make it blue."),
|
||||||
|
],
|
||||||
|
tools: [imageTool],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.store).toBe(false)
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Generate a black triangle." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Make it blue." }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
|
@ -1259,6 +1334,69 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||||
|
},
|
||||||
|
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item_1",
|
||||||
|
call_id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"partial',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.updateRequest(request, {
|
||||||
|
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
|
||||||
|
type: "tool-input-error",
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
})
|
||||||
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
|
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("settles malformed function arguments when output_item.added is absent", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item_1",
|
||||||
|
call_id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"partial',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
})
|
||||||
|
expect(response.finishReason).toBe("tool-calls")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const item = {
|
const item = {
|
||||||
|
|
@ -1298,6 +1436,59 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("decodes image generation output as image content", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const item = {
|
||||||
|
type: "image_generation_call",
|
||||||
|
id: "ig_1",
|
||||||
|
status: "completed",
|
||||||
|
result: "AQID",
|
||||||
|
}
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "response.output_item.done", item },
|
||||||
|
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
|
||||||
|
id: "ig_1",
|
||||||
|
name: "image_generation",
|
||||||
|
providerExecuted: true,
|
||||||
|
result: {
|
||||||
|
type: "content",
|
||||||
|
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects malformed image generation base64", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: { type: "image_generation_call", id: "ig_bad", status: "completed", result: "%%%" },
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: {} },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||||
|
expect(error.message).toContain("invalid image base64")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
|
it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const item = {
|
const item = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM } from "../../src"
|
import { LLM, Message } from "../../src"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import * as OpenRouter from "../../src/providers/openrouter"
|
import * as OpenRouter from "../../src/providers/openrouter"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
|
|
@ -53,4 +53,102 @@ describe("OpenRouter", () => {
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves manually supplied reasoning details", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [
|
||||||
|
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
|
||||||
|
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
|
||||||
|
]
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Thinking",
|
||||||
|
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
reasoning: "Thinking",
|
||||||
|
reasoning_details: details,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves opaque and duplicate continuation details", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [
|
||||||
|
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
|
||||||
|
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||||
|
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||||
|
]
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||||
|
messages: [
|
||||||
|
Message.assistant({
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Thinking",
|
||||||
|
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("does not merge distinct adjacent reasoning text blocks", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const details = [
|
||||||
|
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
|
||||||
|
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
|
||||||
|
]
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||||
|
messages: [
|
||||||
|
Message.assistant({
|
||||||
|
type: "reasoning",
|
||||||
|
text: "AB",
|
||||||
|
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([
|
||||||
|
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("omits scalar reasoning without continuation details", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||||
|
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
33
packages/ai/test/provider/xai-images.recorded.test.ts
Normal file
33
packages/ai/test/provider/xai-images.recorded.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { Image } from "../../src"
|
||||||
|
import { XAI } from "../../src/providers"
|
||||||
|
import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
|
const model = XAI.configure({
|
||||||
|
apiKey: process.env.XAI_API_KEY ?? "fixture",
|
||||||
|
}).image("grok-imagine-image")
|
||||||
|
|
||||||
|
const recorded = recordedTests({
|
||||||
|
prefix: "xai-images",
|
||||||
|
provider: "xai",
|
||||||
|
protocol: "xai-images",
|
||||||
|
requires: ["XAI_API_KEY"],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("xAI Images recorded", () => {
|
||||||
|
recorded.effect("generates an image", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model,
|
||||||
|
prompt: "A simple flat black diamond centered on a plain white background.",
|
||||||
|
options: { aspectRatio: "1:1", resolution: "1k", responseFormat: "b64_json" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(1)
|
||||||
|
expect(response.image?.mediaType.startsWith("image/")).toBe(true)
|
||||||
|
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||||
|
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
109
packages/ai/test/provider/xai-images.test.ts
Normal file
109
packages/ai/test/provider/xai-images.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { Image, ImageClient } from "../../src"
|
||||||
|
import { XAI } from "../../src/providers"
|
||||||
|
import { Auth } from "../../src/route"
|
||||||
|
import { it } from "../lib/effect"
|
||||||
|
import { dynamicResponse } from "../lib/http"
|
||||||
|
|
||||||
|
describe("xAI Images", () => {
|
||||||
|
it.effect("generates through the OpenAI-compatible Images API", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Image.generate({
|
||||||
|
model: XAI.configure({
|
||||||
|
apiKey: "test",
|
||||||
|
baseURL: "https://api.xai.test/v1",
|
||||||
|
http: { body: { configured: true }, headers: { "x-default": "yes" } },
|
||||||
|
}).image("grok-imagine-image"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
options: {
|
||||||
|
n: 2,
|
||||||
|
aspectRatio: "16:9",
|
||||||
|
aspect_ratio: "4:3",
|
||||||
|
resolution: "1k",
|
||||||
|
responseFormat: "url",
|
||||||
|
response_format: "b64_json",
|
||||||
|
future_option: true,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
body: { resolution: "2k", future_option: "http" },
|
||||||
|
headers: { "x-request": "yes" },
|
||||||
|
query: { trace: "1" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(response.images).toHaveLength(2)
|
||||||
|
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||||
|
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
|
||||||
|
expect(response.images[1]?.mediaType).toBe("application/octet-stream")
|
||||||
|
expect(response.images[1]?.data).toBe("https://api.xai.test/image.jpg")
|
||||||
|
expect(response.usage?.providerMetadata).toEqual({ xai: { num_images: 2 } })
|
||||||
|
expect(response.providerMetadata).toEqual({ xai: { usage: { num_images: 2 } } })
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.url).toBe("https://api.xai.test/v1/images/generations?trace=1")
|
||||||
|
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||||
|
expect(request.headers.get("x-default")).toBe("yes")
|
||||||
|
expect(request.headers.get("x-request")).toBe("yes")
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "grok-imagine-image",
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
n: 2,
|
||||||
|
aspect_ratio: "4:3",
|
||||||
|
resolution: "2k",
|
||||||
|
response_format: "b64_json",
|
||||||
|
future_option: "http",
|
||||||
|
configured: true,
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
JSON.stringify({
|
||||||
|
data: [
|
||||||
|
{ b64_json: "AQID", url: null, mime_type: "image/jpeg" },
|
||||||
|
{ b64_json: null, url: "https://api.xai.test/image.jpg", mime_type: null },
|
||||||
|
],
|
||||||
|
usage: { num_images: 2 },
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("supports request-level custom auth", () =>
|
||||||
|
Image.generate({
|
||||||
|
model: XAI.configure({
|
||||||
|
baseURL: "https://api.xai.test/v1",
|
||||||
|
auth: Auth.custom((input) =>
|
||||||
|
Effect.succeed(Headers.set(input.headers, "x-custom-auth", new URL(input.url).hostname)),
|
||||||
|
),
|
||||||
|
}).image("grok-imagine-image"),
|
||||||
|
prompt: "A robot tending a rooftop garden",
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
ImageClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(request.headers.get("x-custom-auth")).toBe("api.xai.test")
|
||||||
|
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -120,29 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
|
||||||
throw new Error("Weather tool loop exceeded 10 steps")
|
throw new Error("Weather tool loop exceeded 10 steps")
|
||||||
})
|
})
|
||||||
|
|
||||||
const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
|
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
|
||||||
const content: ContentPart[] = []
|
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
|
||||||
for (const event of events) {
|
|
||||||
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
|
||||||
const type = event.type === "text-delta" ? "text" : "reasoning"
|
|
||||||
const last = content.at(-1)
|
|
||||||
if (last?.type === type) {
|
|
||||||
content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
|
|
||||||
} else {
|
|
||||||
content.push({ type, text: event.text })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (event.type === "text-end" || event.type === "reasoning-end") {
|
|
||||||
const type = event.type === "text-end" ? "text" : "reasoning"
|
|
||||||
const last = content.at(-1)
|
|
||||||
if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (event.type === "tool-call") content.push(event)
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
export const expectFinish = (
|
export const expectFinish = (
|
||||||
events: ReadonlyArray<LLMEvent>,
|
events: ReadonlyArray<LLMEvent>,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { Layer } from "effect"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
|
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
|
||||||
|
import { ImageClient } from "../src/image-client"
|
||||||
|
import type { Service as ImageClientService } from "../src/image-client"
|
||||||
import type { Service as LLMClientService } from "../src/route/client"
|
import type { Service as LLMClientService } from "../src/route/client"
|
||||||
import type { Service as RequestExecutorService } from "../src/route/executor"
|
import type { Service as RequestExecutorService } from "../src/route/executor"
|
||||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
||||||
|
|
@ -15,7 +17,7 @@ import {
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||||
|
|
||||||
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
|
||||||
|
|
||||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||||
readonly options?: HttpRecorder.RecorderOptions
|
readonly options?: HttpRecorder.RecorderOptions
|
||||||
|
|
@ -81,6 +83,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
||||||
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
|
return Layer.mergeAll(
|
||||||
|
deps,
|
||||||
|
LLMClient.layer.pipe(Layer.provide(deps)),
|
||||||
|
ImageClient.layer.pipe(Layer.provide(deps)),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -95,4 +95,19 @@ describe("LLMResponse reducer", () => {
|
||||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("clears malformed tool input without appending an executable call", () => {
|
||||||
|
const state = reduce([
|
||||||
|
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||||
|
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query":"partial' }),
|
||||||
|
LLMEvent.toolInputError({
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(state.toolInputs).toEqual({})
|
||||||
|
expect(state.message.content).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,33 @@ describe("ToolStream", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const first = ToolStream.appendOrStart(
|
||||||
|
ADAPTER,
|
||||||
|
ToolStream.empty<number>(),
|
||||||
|
0,
|
||||||
|
{ id: "call_1", name: "lookup", text: '{"query"' },
|
||||||
|
"missing tool",
|
||||||
|
)
|
||||||
|
if (ToolStream.isError(first)) return yield* first
|
||||||
|
const second = ToolStream.appendOrStart(
|
||||||
|
ADAPTER,
|
||||||
|
first.tools,
|
||||||
|
0,
|
||||||
|
{ id: "", name: "", text: ':"weather"}' },
|
||||||
|
"missing tool",
|
||||||
|
)
|
||||||
|
if (ToolStream.isError(second)) return yield* second
|
||||||
|
const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
|
||||||
|
|
||||||
|
expect(finished.events).toEqual([
|
||||||
|
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||||
|
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails appendExisting when the provider skipped the tool start", () =>
|
it.effect("fails appendExisting when the provider skipped the tool start", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||||
|
|
@ -64,6 +91,73 @@ describe("ToolStream", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("finalizes malformed local input as a non-executable tool error", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
input: '{"query":"partial',
|
||||||
|
})
|
||||||
|
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
|
||||||
|
|
||||||
|
expect(finished).toEqual({
|
||||||
|
tools: {},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "tool-input-error",
|
||||||
|
id: "call_1",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves valid siblings when one parallel input is malformed", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||||
|
id: "call_valid",
|
||||||
|
name: "lookup",
|
||||||
|
input: '{"query":"weather"}',
|
||||||
|
})
|
||||||
|
const tools = ToolStream.start(valid, 1, {
|
||||||
|
id: "call_invalid",
|
||||||
|
name: "lookup",
|
||||||
|
input: '{"query":"partial',
|
||||||
|
})
|
||||||
|
const finished = yield* ToolStream.finishAll(ADAPTER, tools)
|
||||||
|
|
||||||
|
expect(finished).toEqual({
|
||||||
|
tools: {},
|
||||||
|
events: [
|
||||||
|
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
|
||||||
|
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
|
||||||
|
{
|
||||||
|
type: "tool-input-error",
|
||||||
|
id: "call_invalid",
|
||||||
|
name: "lookup",
|
||||||
|
raw: '{"query":"partial',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("keeps malformed provider-executed input terminal", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||||
|
id: "call_1",
|
||||||
|
name: "web_search",
|
||||||
|
input: '{"query":"partial',
|
||||||
|
providerExecuted: true,
|
||||||
|
})
|
||||||
|
const result = yield* Effect.exit(ToolStream.finish(ADAPTER, tools, "item_1"))
|
||||||
|
|
||||||
|
expect(result._tag).toBe("Failure")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("preserves providerExecuted and clears all tools", () =>
|
it.effect("preserves providerExecuted and clears all tools", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||||
|
|
|
||||||
9
packages/ai/tsconfig.types.json
Normal file
9
packages/ai/tsconfig.types.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["test/**/*.types.ts"]
|
||||||
|
}
|
||||||
|
|
@ -31,11 +31,13 @@ function run(target) {
|
||||||
|
|
||||||
const envPath = process.env.OPENCODE_BIN_PATH
|
const envPath = process.env.OPENCODE_BIN_PATH
|
||||||
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
const scriptDir = path.dirname(fs.realpathSync(__filename))
|
||||||
const cached = path.join(scriptDir, ".opencode2")
|
const command = path.basename(__filename).replace(/\.cjs$/, "")
|
||||||
|
const nodeBuild = command === "opencode2-node"
|
||||||
|
const cached = path.join(scriptDir, `.${command}`)
|
||||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
||||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
||||||
const base = "@opencode-ai/cli-" + platform + "-" + arch
|
const base = `@opencode-ai/cli${nodeBuild ? "-node" : ""}-` + platform + "-" + arch
|
||||||
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
|
const binary = platform === "windows" ? `${command}.exe` : command
|
||||||
|
|
||||||
function supportsAvx2() {
|
function supportsAvx2() {
|
||||||
if (arch !== "x64") return false
|
if (arch !== "x64") return false
|
||||||
|
|
@ -77,6 +79,7 @@ function supportsAvx2() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = (() => {
|
const names = (() => {
|
||||||
|
if (nodeBuild) return [base]
|
||||||
const baseline = arch === "x64" && !supportsAvx2()
|
const baseline = arch === "x64" && !supportsAvx2()
|
||||||
if (platform === "linux") {
|
if (platform === "linux") {
|
||||||
const musl = (() => {
|
const musl = (() => {
|
||||||
|
|
@ -121,7 +124,7 @@ function findBinary(startDir) {
|
||||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
console.error(
|
console.error(
|
||||||
"It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
|
`It seems that your package manager failed to install the right ${command} CLI package. Try manually installing ` +
|
||||||
names.map((name) => `"${name}"`).join(" or ") +
|
names.map((name) => `"${name}"`).join(" or ") +
|
||||||
" package",
|
" package",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,20 +12,12 @@
|
||||||
],
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
"./daemon": "./src/daemon.ts",
|
"./daemon": "./src/daemon.ts",
|
||||||
"./mini": "./src/mini/index.ts",
|
"./run": "./src/run/index.ts",
|
||||||
"./mini/footer.command": "./src/mini/footer.command.tsx",
|
|
||||||
"./mini/footer.menu": "./src/mini/footer.menu.tsx",
|
|
||||||
"./mini/footer.permission": "./src/mini/footer.permission.tsx",
|
|
||||||
"./mini/footer.prompt": "./src/mini/footer.prompt.tsx",
|
|
||||||
"./mini/footer.question": "./src/mini/footer.question.tsx",
|
|
||||||
"./mini/footer.subagent": "./src/mini/footer.subagent.tsx",
|
|
||||||
"./mini/footer.view": "./src/mini/footer.view.tsx",
|
|
||||||
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
|
|
||||||
"./mini/*": "./src/mini/*.ts",
|
|
||||||
"./server-process": "./src/server-process.ts"
|
"./server-process": "./src/server-process.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run script/build.ts",
|
"build": "bun run script/build.ts",
|
||||||
|
"build:node": "bun run script/build-node.ts",
|
||||||
"dev": "bun run src/index.ts",
|
"dev": "bun run src/index.ts",
|
||||||
"test": "bun test --timeout 30000 --only-failures",
|
"test": "bun test --timeout 30000 --only-failures",
|
||||||
"typecheck": "tsgo --noEmit"
|
"typecheck": "tsgo --noEmit"
|
||||||
|
|
@ -39,19 +31,16 @@
|
||||||
"@opencode-ai/server": "workspace:*",
|
"@opencode-ai/server": "workspace:*",
|
||||||
"@opencode-ai/tui": "workspace:*",
|
"@opencode-ai/tui": "workspace:*",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "catalog:",
|
|
||||||
"semver": "catalog:",
|
"semver": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"uqr": "0.1.3",
|
||||||
"uqr": "0.1.3"
|
"ws": "8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
|
|
@ -59,6 +48,19 @@
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/semver": "catalog:",
|
"@types/semver": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:"
|
"@typescript/native-preview": "catalog:",
|
||||||
|
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||||
|
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||||
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||||
|
"@parcel/watcher-win32-x64": "2.5.1",
|
||||||
|
"vite": "catalog:",
|
||||||
|
"vite-plugin-solid": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
203
packages/cli/script/build-node.ts
Normal file
203
packages/cli/script/build-node.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { spawnSync } from "node:child_process"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { build } from "vite"
|
||||||
|
import { Script } from "@opencode-ai/script"
|
||||||
|
import pkg from "../package.json"
|
||||||
|
import { modelsData } from "./generate"
|
||||||
|
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
|
||||||
|
import { mainConfig } from "../vite.node.config"
|
||||||
|
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||||
|
|
||||||
|
const NODE_VERSION = "26.4.0"
|
||||||
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
|
const outdir = path.resolve(
|
||||||
|
dir,
|
||||||
|
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
|
||||||
|
)
|
||||||
|
if (outdir === dir) throw new Error("--outdir must not be the package directory")
|
||||||
|
if (outdir === path.join(dir, "dist-node")) {
|
||||||
|
throw new Error("--outdir must not be dist-node because it contains temporary files")
|
||||||
|
}
|
||||||
|
const bundleOnly = process.argv.includes("--bundle-only")
|
||||||
|
const single = process.argv.includes("--single")
|
||||||
|
const skipInstall = process.argv.includes("--skip-install")
|
||||||
|
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
|
||||||
|
const allTargets = [
|
||||||
|
nodeTarget("linux", "arm64"),
|
||||||
|
nodeTarget("linux", "x64"),
|
||||||
|
nodeTarget("darwin", "arm64"),
|
||||||
|
nodeTarget("win32", "arm64"),
|
||||||
|
nodeTarget("win32", "x64"),
|
||||||
|
]
|
||||||
|
const targets = requested
|
||||||
|
? allTargets.filter((target) => targetName(target) === requested)
|
||||||
|
: single || bundleOnly
|
||||||
|
? [nodeTarget(process.platform, process.arch)]
|
||||||
|
: allTargets
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
if (requested === "darwin-x64") throw new Error("Node 26.4 SEA does not support macOS x64")
|
||||||
|
throw new Error(`Unknown Node target: ${requested}`)
|
||||||
|
}
|
||||||
|
if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) {
|
||||||
|
throw new Error("Node 26.4 SEA does not support macOS x64")
|
||||||
|
}
|
||||||
|
|
||||||
|
process.chdir(dir)
|
||||||
|
if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"])
|
||||||
|
if (!bundleOnly) await rm(outdir, { recursive: true, force: true })
|
||||||
|
const builder =
|
||||||
|
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
|
||||||
|
? await resolveHostNode()
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
console.log(`building cli-node-${targetName(target)}`)
|
||||||
|
const assets = await collectNodeAssets(target)
|
||||||
|
await rm("dist-node", { recursive: true, force: true })
|
||||||
|
const assetHash = await hashNodeAssets(assets)
|
||||||
|
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||||
|
await build(mainConfig(input))
|
||||||
|
await copyNodeAssets(assets)
|
||||||
|
|
||||||
|
const host = target.platform === process.platform && target.arch === process.arch
|
||||||
|
if (host) {
|
||||||
|
if (!builder) throw new Error("Node SEA builder is unavailable")
|
||||||
|
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--version"])
|
||||||
|
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--help"])
|
||||||
|
}
|
||||||
|
if (bundleOnly) continue
|
||||||
|
|
||||||
|
const name = `cli-node-${targetName(target)}`
|
||||||
|
const binary = target.platform === "win32" ? "opencode2-node.exe" : "opencode2-node"
|
||||||
|
const output = path.join(outdir, name, "bin", binary)
|
||||||
|
if (!builder) throw new Error("Node SEA builder is unavailable")
|
||||||
|
await mkdir(path.dirname(output), { recursive: true })
|
||||||
|
const config = {
|
||||||
|
main: "dist-node/opencode.mjs",
|
||||||
|
mainFormat: "module",
|
||||||
|
executable: await resolveTargetNode(target, builder),
|
||||||
|
output: path.relative(dir, output),
|
||||||
|
disableExperimentalSEAWarning: true,
|
||||||
|
useSnapshot: false,
|
||||||
|
useCodeCache: false,
|
||||||
|
execArgv: nodeExecArgv,
|
||||||
|
execArgvExtension: "none",
|
||||||
|
assets: await seaAssetMap(),
|
||||||
|
}
|
||||||
|
await writeFile("dist-node/sea.json", `${JSON.stringify(config, null, 2)}\n`)
|
||||||
|
run(builder, ["--build-sea", "dist-node/sea.json"])
|
||||||
|
if (target.platform !== "win32") await chmod(output, 0o755)
|
||||||
|
if (target.platform === "darwin" && process.platform === "darwin") run("codesign", ["--sign", "-", output])
|
||||||
|
if (target.platform === "darwin" && process.platform !== "darwin") {
|
||||||
|
console.warn(`${output} must be signed on macOS before it can run`)
|
||||||
|
}
|
||||||
|
await writeFile(
|
||||||
|
path.join(outdir, name, "package.json"),
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
name: `@opencode-ai/${name}`,
|
||||||
|
version: Script.version,
|
||||||
|
license: pkg.license,
|
||||||
|
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||||
|
os: [target.platform],
|
||||||
|
cpu: [target.arch],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
)
|
||||||
|
if (host) await smoke(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHostNode() {
|
||||||
|
const candidates = [process.env.NODE_BIN, "node"].filter((item): item is string => Boolean(item))
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = spawnSync(
|
||||||
|
candidate,
|
||||||
|
["-p", "JSON.stringify({version:process.versions.node,path:process.execPath})"],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (result.status !== 0) continue
|
||||||
|
const info = JSON.parse(result.stdout) as { version: string; path: string }
|
||||||
|
if (info.version === NODE_VERSION) return realpath(info.path)
|
||||||
|
}
|
||||||
|
return resolveTargetNode(nodeTarget(process.platform, process.arch))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTargetNode(target: NodeTarget, host?: string) {
|
||||||
|
if (host && target.platform === process.platform && target.arch === process.arch) return host
|
||||||
|
const cache = path.resolve(dir, ".cache", "node")
|
||||||
|
const platform = target.platform === "win32" ? "win" : target.platform
|
||||||
|
const archiveName = `node-v${NODE_VERSION}-${platform}-${target.arch}`
|
||||||
|
const targetDirectory = path.join(cache, archiveName)
|
||||||
|
const executable = path.join(targetDirectory, target.platform === "win32" ? "node.exe" : "bin/node")
|
||||||
|
if (
|
||||||
|
(await stat(executable).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
)) &&
|
||||||
|
(await stat(path.join(targetDirectory, ".verified")).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return realpath(executable)
|
||||||
|
await mkdir(cache, { recursive: true })
|
||||||
|
const extension = target.platform === "win32" ? "zip" : "tar.gz"
|
||||||
|
const filename = `${archiveName}.${extension}`
|
||||||
|
const archive = path.join(cache, filename)
|
||||||
|
const base = `https://nodejs.org/dist/v${NODE_VERSION}`
|
||||||
|
const [response, sums] = await Promise.all([fetch(`${base}/${filename}`), fetch(`${base}/SHASUMS256.txt`)])
|
||||||
|
if (!response.ok) throw new Error(`Failed to download Node ${NODE_VERSION}: ${response.status}`)
|
||||||
|
if (!sums.ok) throw new Error(`Failed to download Node ${NODE_VERSION} checksums: ${sums.status}`)
|
||||||
|
const data = new Uint8Array(await response.arrayBuffer())
|
||||||
|
const expected = (await sums.text())
|
||||||
|
.split("\n")
|
||||||
|
.find((line) => line.endsWith(` ${filename}`))
|
||||||
|
?.split(/\s+/)[0]
|
||||||
|
if (!expected) throw new Error(`Missing checksum for ${filename}`)
|
||||||
|
if (createHash("sha256").update(data).digest("hex") !== expected) throw new Error(`Checksum mismatch for ${filename}`)
|
||||||
|
await writeFile(archive, data)
|
||||||
|
const temporary = path.join(cache, `${archiveName}.${process.pid}.tmp`)
|
||||||
|
await rm(temporary, { recursive: true, force: true })
|
||||||
|
await mkdir(temporary)
|
||||||
|
if (target.platform !== "win32") run("tar", ["-xzf", archive, "-C", temporary])
|
||||||
|
if (target.platform === "win32" && process.platform === "win32") {
|
||||||
|
run(path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "tar.exe"), ["-xf", archive, "-C", temporary])
|
||||||
|
}
|
||||||
|
if (target.platform === "win32" && process.platform !== "win32") run("unzip", ["-q", archive, "-d", temporary])
|
||||||
|
await rm(targetDirectory, { recursive: true, force: true })
|
||||||
|
await rename(path.join(temporary, archiveName), targetDirectory)
|
||||||
|
await writeFile(path.join(targetDirectory, ".verified"), `${expected}\n`)
|
||||||
|
await rm(temporary, { recursive: true, force: true })
|
||||||
|
await rm(archive, { force: true })
|
||||||
|
return realpath(executable)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function smoke(output: string) {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-node-smoke-"))
|
||||||
|
const executable = path.join(root, path.basename(output))
|
||||||
|
await copyFile(output, executable)
|
||||||
|
if (process.platform !== "win32") await chmod(executable, 0o755)
|
||||||
|
run(executable, ["--version"], root)
|
||||||
|
run(executable, ["--help"], root)
|
||||||
|
await rm(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetName(target: NodeTarget) {
|
||||||
|
return `${target.platform === "win32" ? "windows" : target.platform}-${target.arch}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command: string, args: readonly string[], cwd = dir) {
|
||||||
|
const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env })
|
||||||
|
if (result.error) throw result.error
|
||||||
|
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import fs from "fs"
|
|
||||||
import { rm } from "fs/promises"
|
import { rm } from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
|
|
@ -11,9 +10,14 @@ import { modelsData } from "./generate"
|
||||||
|
|
||||||
const dir = path.resolve(import.meta.dirname, "..")
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
const binary = "opencode2"
|
const binary = "opencode2"
|
||||||
|
const outdir = path.resolve(
|
||||||
|
dir,
|
||||||
|
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
|
||||||
|
)
|
||||||
|
if (outdir === dir) throw new Error("--outdir must not be the package directory")
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
await rm("dist", { recursive: true, force: true })
|
await rm(outdir, { recursive: true, force: true })
|
||||||
|
|
||||||
const singleFlag = process.argv.includes("--single")
|
const singleFlag = process.argv.includes("--single")
|
||||||
const baselineFlag = process.argv.includes("--baseline")
|
const baselineFlag = process.argv.includes("--baseline")
|
||||||
|
|
@ -50,10 +54,6 @@ const targets = singleFlag
|
||||||
|
|
||||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||||
|
|
||||||
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
|
||||||
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
|
||||||
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
|
|
||||||
|
|
||||||
for (const item of targets) {
|
for (const item of targets) {
|
||||||
const target = [
|
const target = [
|
||||||
binary,
|
binary,
|
||||||
|
|
@ -67,7 +67,7 @@ for (const item of targets) {
|
||||||
const name = target.replace(binary, "cli")
|
const name = target.replace(binary, "cli")
|
||||||
console.log(`building ${name}`)
|
console.log(`building ${name}`)
|
||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
entrypoints: ["./src/index.ts", parserWorker],
|
entrypoints: ["./src/index.ts"],
|
||||||
tsconfig: "./tsconfig.json",
|
tsconfig: "./tsconfig.json",
|
||||||
plugins: [plugin],
|
plugins: [plugin],
|
||||||
external: ["node-gyp"],
|
external: ["node-gyp"],
|
||||||
|
|
@ -81,7 +81,7 @@ for (const item of targets) {
|
||||||
autoloadTsconfig: true,
|
autoloadTsconfig: true,
|
||||||
autoloadPackageJson: true,
|
autoloadPackageJson: true,
|
||||||
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
|
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
|
||||||
outfile: `./dist/${name}/bin/${binary}`,
|
outfile: path.join(outdir, name, "bin", binary),
|
||||||
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
|
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
|
||||||
windows: {},
|
windows: {},
|
||||||
},
|
},
|
||||||
|
|
@ -93,10 +93,6 @@ for (const item of targets) {
|
||||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||||
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
|
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
|
||||||
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
|
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
|
||||||
OTUI_TREE_SITTER_WORKER_PATH:
|
|
||||||
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
|
|
||||||
path.relative(dir, parserWorker).replaceAll("\\", "/") +
|
|
||||||
'"',
|
|
||||||
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
|
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -107,7 +103,7 @@ for (const item of targets) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
`./dist/${name}/package.json`,
|
path.join(outdir, name, "package.json"),
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
name: `@opencode-ai/${name}`,
|
name: `@opencode-ai/${name}`,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
|
||||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||||
|
|
||||||
export const modelsData = process.env.MODELS_DEV_API_JSON
|
export const modelsData = process.env.MODELS_DEV_API_JSON
|
||||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
|
||||||
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
|
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
|
||||||
|
|
||||||
console.log("Loaded models.dev snapshot")
|
console.log("Loaded models.dev snapshot")
|
||||||
|
|
|
||||||
83
packages/cli/script/node-assets.ts
Normal file
83
packages/cli/script/node-assets.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||||
|
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
|
||||||
|
|
||||||
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
|
|
||||||
|
// Bun's compiler discovers file imports and embeds them in its virtual filesystem. Vite only bundles the JavaScript
|
||||||
|
// portion of the Node executable, while SEA embeds only the assets explicitly listed in its build configuration.
|
||||||
|
// Collect and stage those files under stable keys so the SEA prelude can extract them to real paths at startup;
|
||||||
|
// native addons, helper executables, and other path-based consumers cannot use assets directly from SEA memory.
|
||||||
|
export type NodeAsset = {
|
||||||
|
readonly key: string
|
||||||
|
readonly source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function files(root: string, current = root): Promise<string[]> {
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||||
|
const target = path.join(current, entry.name)
|
||||||
|
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).flat()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectNodeAssets(target: NodeTarget) {
|
||||||
|
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||||
|
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||||
|
const assets: NodeAsset[] = [
|
||||||
|
...getNodeAssets({
|
||||||
|
platform: target.platform,
|
||||||
|
arch: target.arch,
|
||||||
|
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
|
||||||
|
}),
|
||||||
|
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
|
||||||
|
{
|
||||||
|
key: photonWasmAsset,
|
||||||
|
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
|
||||||
|
},
|
||||||
|
...attentionSoundAssets.map((key) => ({
|
||||||
|
key,
|
||||||
|
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||||
|
})),
|
||||||
|
...(await files(ptyRoot))
|
||||||
|
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||||
|
.map((relative) => ({
|
||||||
|
key: `${target.nodePtyPackage}/${relative}`,
|
||||||
|
source: path.join(ptyRoot, relative),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
await Promise.all(assets.map((asset) => stat(asset.source)))
|
||||||
|
return assets
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
|
||||||
|
const hash = createHash("sha256")
|
||||||
|
for (const asset of assets.toSorted((left, right) => left.key.localeCompare(right.key))) {
|
||||||
|
hash.update(asset.key)
|
||||||
|
hash.update(await readFile(asset.source))
|
||||||
|
}
|
||||||
|
return hash.digest("hex").slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyNodeAssets(assets: readonly NodeAsset[]) {
|
||||||
|
const root = path.join(dir, "dist-node", "assets")
|
||||||
|
await Promise.all(
|
||||||
|
assets.map(async (asset) => {
|
||||||
|
const target = path.join(root, asset.key)
|
||||||
|
await mkdir(path.dirname(target), { recursive: true })
|
||||||
|
await copyFile(asset.source, target)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seaAssetMap() {
|
||||||
|
const root = path.join(dir, "dist-node", "assets")
|
||||||
|
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
|
||||||
|
}
|
||||||
161
packages/cli/script/postinstall.mjs
Normal file
161
packages/cli/script/postinstall.mjs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import childProcess from "node:child_process"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const directory = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"))
|
||||||
|
const command = Object.keys(packageJson.bin ?? {})[0]
|
||||||
|
if (!command) throw new Error("OpenCode package does not declare a binary")
|
||||||
|
|
||||||
|
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
|
||||||
|
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
|
||||||
|
const sourceBinary = platform === "windows" ? `${command}.exe` : command
|
||||||
|
const targetBinary = path.resolve(directory, packageJson.bin[command])
|
||||||
|
const dependencies = packageJson.optionalDependencies ?? {}
|
||||||
|
const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`))
|
||||||
|
if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`)
|
||||||
|
|
||||||
|
function supportsAvx2() {
|
||||||
|
if (arch !== "x64") return false
|
||||||
|
if (platform === "linux") {
|
||||||
|
try {
|
||||||
|
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === "darwin") {
|
||||||
|
try {
|
||||||
|
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 1500,
|
||||||
|
})
|
||||||
|
return result.status === 0 && (result.stdout || "").trim() === "1"
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (platform === "windows") {
|
||||||
|
const script =
|
||||||
|
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||||
|
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||||
|
try {
|
||||||
|
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", script], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 3000,
|
||||||
|
windowsHide: true,
|
||||||
|
})
|
||||||
|
if (result.status !== 0) continue
|
||||||
|
const output = (result.stdout || "").trim().toLowerCase()
|
||||||
|
if (output === "true" || output === "1") return true
|
||||||
|
if (output === "false" || output === "0") return false
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMusl() {
|
||||||
|
if (platform !== "linux") return false
|
||||||
|
try {
|
||||||
|
if (fs.existsSync("/etc/alpine-release")) return true
|
||||||
|
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||||
|
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageNames() {
|
||||||
|
const baseline = arch === "x64" && !supportsAvx2()
|
||||||
|
const names =
|
||||||
|
platform === "linux"
|
||||||
|
? isMusl()
|
||||||
|
? arch === "x64"
|
||||||
|
? baseline
|
||||||
|
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||||
|
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||||
|
: [`${base}-musl`, base]
|
||||||
|
: arch === "x64"
|
||||||
|
? baseline
|
||||||
|
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||||
|
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||||
|
: [base, `${base}-musl`]
|
||||||
|
: arch === "x64"
|
||||||
|
? baseline
|
||||||
|
? [`${base}-baseline`, base]
|
||||||
|
: [base, `${base}-baseline`]
|
||||||
|
: [base]
|
||||||
|
return names.filter((name) => dependencies[name])
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyBinary(source) {
|
||||||
|
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||||
|
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
|
||||||
|
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
|
||||||
|
try {
|
||||||
|
fs.linkSync(source, targetBinary)
|
||||||
|
} catch {
|
||||||
|
fs.copyFileSync(source, targetBinary)
|
||||||
|
}
|
||||||
|
fs.chmodSync(targetBinary, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBinary(name) {
|
||||||
|
const packagePath = require.resolve(`${name}/package.json`)
|
||||||
|
return path.join(path.dirname(packagePath), "bin", sourceBinary)
|
||||||
|
}
|
||||||
|
|
||||||
|
function installPackage(name) {
|
||||||
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||||
|
try {
|
||||||
|
const result = childProcess.spawnSync(
|
||||||
|
"npm",
|
||||||
|
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
|
||||||
|
{ stdio: "inherit", windowsHide: true },
|
||||||
|
)
|
||||||
|
if (result.status !== 0) return false
|
||||||
|
copyBinary(path.join(temp, "node_modules", name, "bin", sourceBinary))
|
||||||
|
return true
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temp, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyBinary() {
|
||||||
|
return (
|
||||||
|
childProcess.spawnSync(targetBinary, ["--version"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
windowsHide: true,
|
||||||
|
}).status === 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const names = packageNames()
|
||||||
|
for (const name of names) {
|
||||||
|
try {
|
||||||
|
copyBinary(resolveBinary(name))
|
||||||
|
if (verifyBinary()) return
|
||||||
|
} catch {
|
||||||
|
if (installPackage(name) && verifyBinary()) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
@ -18,37 +18,66 @@ async function publish(dir: string, name: string, version: string) {
|
||||||
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
|
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
const binaries: Record<string, string> = {}
|
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
|
||||||
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
|
const binaries: Record<string, string> = {}
|
||||||
const item = await Bun.file(`./dist/${filepath}`).json()
|
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
|
||||||
binaries[item.name] = item.version
|
const item = await Bun.file(`${input.root}/${filepath}`).json()
|
||||||
|
if (!item.name.startsWith(input.packagePrefix)) continue
|
||||||
|
binaries[item.name] = item.version
|
||||||
|
}
|
||||||
|
console.log(input.name, "binaries", binaries)
|
||||||
|
const versions = new Set(Object.values(binaries))
|
||||||
|
if (versions.size > 1) throw new Error(`Binary package versions do not match for ${input.name}`)
|
||||||
|
const version = versions.values().next().value
|
||||||
|
if (!version) throw new Error(`No binary packages found for ${input.name}`)
|
||||||
|
|
||||||
|
await $`mkdir -p ${input.root}/${input.name}/bin`
|
||||||
|
await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs`
|
||||||
|
await Bun.file(`${input.root}/${input.name}/bin/${input.binary}.exe`).write(
|
||||||
|
[
|
||||||
|
`echo "Error: ${input.name}'s postinstall script was not run." >&2`,
|
||||||
|
'echo "" >&2',
|
||||||
|
'echo "This occurs when installation scripts are disabled." >&2',
|
||||||
|
'echo "Run the package postinstall script or reinstall with scripts enabled." >&2',
|
||||||
|
"exit 1",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await Bun.file(`${input.root}/${input.name}/package.json`).write(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: input.name,
|
||||||
|
bin: { [input.binary]: `./bin/${input.binary}.exe` },
|
||||||
|
scripts: { postinstall: "node ./postinstall.mjs" },
|
||||||
|
version,
|
||||||
|
license: pkg.license,
|
||||||
|
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||||
|
os: ["darwin", "linux", "win32"],
|
||||||
|
cpu: ["arm64", "x64"],
|
||||||
|
optionalDependencies: binaries,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(binaries).map(([name, version]) =>
|
||||||
|
publish(`${input.root}/${name.replace("@opencode-ai/", "")}`, name, version),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await publish(`${input.root}/${input.name}`, input.name, version)
|
||||||
}
|
}
|
||||||
console.log("binaries", binaries)
|
|
||||||
const version = Object.values(binaries)[0]
|
|
||||||
const name = pkg.name
|
|
||||||
|
|
||||||
await $`mkdir -p ./dist/${name}/bin`
|
await publishDistribution({
|
||||||
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
|
root: "./dist",
|
||||||
await Bun.file(`./dist/${name}/package.json`).write(
|
name: pkg.name,
|
||||||
JSON.stringify(
|
binary: "opencode2",
|
||||||
{
|
packagePrefix: "@opencode-ai/cli-",
|
||||||
name,
|
})
|
||||||
bin: { opencode2: "./bin/opencode2" },
|
await publishDistribution({
|
||||||
version,
|
root: "./dist/node",
|
||||||
license: pkg.license,
|
name: "opencode-node",
|
||||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
binary: "opencode2-node",
|
||||||
os: ["darwin", "linux", "win32"],
|
packagePrefix: "@opencode-ai/cli-node-",
|
||||||
cpu: ["arm64", "x64"],
|
})
|
||||||
optionalDependencies: binaries,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
Object.entries(binaries).map(([name, version]) =>
|
|
||||||
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await publish(`./dist/${name}`, name, version)
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
|
||||||
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
|
const nodeBuild = process.argv.includes("--node")
|
||||||
const directory = path.join(import.meta.dir, "..", "dist", target, "bin")
|
const target = `cli${nodeBuild ? "-node" : ""}-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
|
||||||
const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`)
|
const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["node"] : []), target, "bin")
|
||||||
|
const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`)
|
||||||
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
||||||
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,10 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
Spec.make("plugin", {
|
||||||
|
description: "Manage plugins",
|
||||||
|
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||||
|
}),
|
||||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||||
Spec.make("mini", {
|
Spec.make("mini", {
|
||||||
description: "Start the minimal interactive interface",
|
description: "Start the minimal interactive interface",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { run } from "@opencode-ai/tui"
|
import { run } from "@opencode-ai/tui"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
|
|
@ -75,6 +75,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
: Effect.logInfo(message, tags)
|
: Effect.logInfo(message, tags)
|
||||||
runFork(effect)
|
runFork(effect)
|
||||||
},
|
},
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
}).pipe(Effect.provide(LayerNode.compile(Global.node)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { EOL } from "node:os"
|
import { EOL } from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { readFile, stat, writeFile } from "node:fs/promises"
|
||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { applyEdits, modify } from "jsonc-parser"
|
import { applyEdits, modify } from "jsonc-parser"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
@ -35,7 +36,7 @@ export default Runtime.handler(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function resolveConfigPath(directory: string) {
|
export async function resolveConfigPath(directory: string) {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(directory, "opencode.json"),
|
path.join(directory, "opencode.json"),
|
||||||
path.join(directory, "opencode.jsonc"),
|
path.join(directory, "opencode.jsonc"),
|
||||||
|
|
@ -43,16 +44,24 @@ async function resolveConfigPath(directory: string) {
|
||||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||||
]
|
]
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (await Bun.file(candidate).exists()) return candidate
|
if (
|
||||||
|
await stat(candidate).then(
|
||||||
|
(info) => info.isFile(),
|
||||||
|
() => false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidate
|
||||||
}
|
}
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function write(configPath: string, name: string, server: unknown) {
|
async function write(configPath: string, name: string, server: unknown) {
|
||||||
const file = Bun.file(configPath)
|
const text = await readFile(configPath, "utf8").catch((error) => {
|
||||||
const text = (await file.exists()) ? await file.text() : "{}"
|
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
|
||||||
|
throw error
|
||||||
|
})
|
||||||
const edits = modify(text, ["mcp", "servers", name], server, {
|
const edits = modify(text, ["mcp", "servers", name], server, {
|
||||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||||
})
|
})
|
||||||
await Bun.write(configPath, applyEdits(text, edits))
|
await writeFile(configPath, applyEdits(text, edits))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { Effect, Option } from "effect"
|
import { Context, Effect, FileSystem, Option } from "effect"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
|
import { Config } from "../../config"
|
||||||
|
import { resolve } from "@opencode-ai/tui/config"
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -9,9 +11,17 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||||
yield* Effect.promise(async () => validateMiniTerminal())
|
yield* Effect.promise(async () => validateMiniTerminal())
|
||||||
const serverURL = Option.getOrUndefined(input.server)
|
const serverURL = Option.getOrUndefined(input.server)
|
||||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||||
|
const fileSystem = yield* FileSystem.FileSystem
|
||||||
|
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||||
|
const service = server.service
|
||||||
yield* Effect.promise(() =>
|
yield* Effect.promise(() =>
|
||||||
runMini({
|
runMini({
|
||||||
server,
|
server: {
|
||||||
|
endpoint: server.endpoint,
|
||||||
|
reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined,
|
||||||
|
},
|
||||||
continue: input.continue,
|
continue: input.continue,
|
||||||
session: Option.getOrUndefined(input.session),
|
session: Option.getOrUndefined(input.session),
|
||||||
fork: input.fork,
|
fork: input.fork,
|
||||||
|
|
@ -21,6 +31,7 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||||
replay: input.replay,
|
replay: input.replay,
|
||||||
replayLimit: Option.getOrUndefined(input.replayLimit),
|
replayLimit: Option.getOrUndefined(input.replayLimit),
|
||||||
demo: input.demo,
|
demo: input.demo,
|
||||||
|
tuiConfig: resolved,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
24
packages/cli/src/commands/handlers/plugin/list.ts
Normal file
24
packages/cli/src/commands/handlers/plugin/list.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { EOL } from "node:os"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
|
import { Commands } from "../../commands"
|
||||||
|
import { Runtime } from "../../../framework/runtime"
|
||||||
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|
||||||
|
export default Runtime.handler(
|
||||||
|
Commands.commands.plugin.commands.list,
|
||||||
|
Effect.fn("cli.plugin.list")(function* () {
|
||||||
|
const options = yield* ServiceConfig.options()
|
||||||
|
const found = yield* Service.discover(options)
|
||||||
|
const endpoint = found ?? (yield* Service.ensure(options))
|
||||||
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
|
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||||
|
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||||
|
if (plugins.length === 0) {
|
||||||
|
process.stdout.write("No plugins loaded" + EOL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run"))
|
||||||
const separator = process.argv.indexOf("--", 2)
|
const separator = process.argv.indexOf("--", 2)
|
||||||
const server = yield* ServerConnection.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { ServerProcess } from "../../server-process"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.serve,
|
Commands.commands.serve,
|
||||||
Effect.fn("cli.serve")(function* (input) {
|
Effect.fnUntraced(function* (input) {
|
||||||
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
|
if (input.service && input.stdio) return yield* Effect.fail(new Error("--service and --stdio cannot be combined"))
|
||||||
return yield* ServerProcess.run({
|
return yield* ServerProcess.run({
|
||||||
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
|
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { Runtime } from "./framework/runtime"
|
||||||
import { Observability } from "@opencode-ai/core/observability"
|
import { Observability } from "@opencode-ai/core/observability"
|
||||||
import { Updater } from "./services/updater"
|
import { Updater } from "./services/updater"
|
||||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
|
|
@ -32,6 +31,9 @@ const Handlers = Runtime.handlers(Commands, {
|
||||||
auth: () => import("./commands/handlers/mcp/auth"),
|
auth: () => import("./commands/handlers/mcp/auth"),
|
||||||
logout: () => import("./commands/handlers/mcp/logout"),
|
logout: () => import("./commands/handlers/mcp/logout"),
|
||||||
},
|
},
|
||||||
|
plugin: {
|
||||||
|
list: () => import("./commands/handlers/plugin/list"),
|
||||||
|
},
|
||||||
migrate: () => import("./commands/handlers/migrate"),
|
migrate: () => import("./commands/handlers/migrate"),
|
||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
|
|
@ -58,7 +60,7 @@ Effect.logInfo("cli starting", {
|
||||||
Effect.annotateLogs({ role: "cli" }),
|
Effect.annotateLogs({ role: "cli" }),
|
||||||
Effect.provide(Config.layer),
|
Effect.provide(Config.layer),
|
||||||
Effect.provide(Updater.layer),
|
Effect.provide(Updater.layer),
|
||||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
||||||
Effect.provide(Observability.layer),
|
Effect.provide(Observability.layer),
|
||||||
Effect.provide(NodeServices.layer),
|
Effect.provide(NodeServices.layer),
|
||||||
Effect.scoped,
|
Effect.scoped,
|
||||||
|
|
|
||||||
176
packages/cli/src/mini-host.ts
Normal file
176
packages/cli/src/mini-host.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||||
|
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
||||||
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import path from "node:path"
|
||||||
|
import { ReadStream } from "node:tty"
|
||||||
|
|
||||||
|
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
|
||||||
|
|
||||||
|
export type InteractiveStdin = {
|
||||||
|
stdin: NodeJS.ReadStream
|
||||||
|
cleanup(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
type MiniHost = MiniFrontendInput["host"]
|
||||||
|
|
||||||
|
function preferences(statePath: string): MiniHost["preferences"] {
|
||||||
|
const repository = createModelPreferenceRepository(path.join(statePath, "model.json"))
|
||||||
|
return {
|
||||||
|
async resolveVariant(model) {
|
||||||
|
if (!model) return
|
||||||
|
return repository.resolveVariant(model)
|
||||||
|
},
|
||||||
|
async saveVariant(model, variant) {
|
||||||
|
if (!model) return
|
||||||
|
await repository.saveVariant(model, variant).catch(() => undefined)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
|
||||||
|
return {
|
||||||
|
subscribe(listener) {
|
||||||
|
let subscribed = true
|
||||||
|
process.on(name, listener)
|
||||||
|
return () => {
|
||||||
|
if (!subscribed) return
|
||||||
|
subscribed = false
|
||||||
|
process.off(name, listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTrace(
|
||||||
|
logPath: string,
|
||||||
|
diagnostics: { pid: number; cwd: string; argv: string[] },
|
||||||
|
): MiniHost["diagnostics"]["trace"] {
|
||||||
|
if (!process.env.OPENCODE_DIRECT_TRACE) return
|
||||||
|
const stamp = new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[-:]/g, "")
|
||||||
|
.replace(/\.\d+Z$/, "Z")
|
||||||
|
const target = path.join(logPath, "direct", `${stamp}-${diagnostics.pid}.jsonl`)
|
||||||
|
const text = (data: unknown) =>
|
||||||
|
JSON.stringify(data, (_key, value) => (typeof value === "bigint" ? String(value) : value), 0)
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(logPath, "direct", "latest.json"),
|
||||||
|
text({
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
...diagnostics,
|
||||||
|
path: target,
|
||||||
|
}) + "\n",
|
||||||
|
)
|
||||||
|
const trace = {
|
||||||
|
write(type: string, data?: unknown) {
|
||||||
|
fs.appendFileSync(
|
||||||
|
target,
|
||||||
|
text({
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
pid: diagnostics.pid,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
}) + "\n",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
trace.write("trace.start", {
|
||||||
|
argv: diagnostics.argv,
|
||||||
|
cwd: diagnostics.cwd,
|
||||||
|
path: target,
|
||||||
|
})
|
||||||
|
return trace
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTerminalStdin(target: string): NodeJS.ReadStream {
|
||||||
|
return new ReadStream(fs.openSync(target, "r"))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInteractiveStdin(
|
||||||
|
stdin: NodeJS.ReadStream = process.stdin,
|
||||||
|
open: (target: string) => NodeJS.ReadStream = openTerminalStdin,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): InteractiveStdin {
|
||||||
|
if (stdin.isTTY) return { stdin, cleanup() {} }
|
||||||
|
const target = platform === "win32" ? "CONIN$" : "/dev/tty"
|
||||||
|
try {
|
||||||
|
const source = open(target)
|
||||||
|
let cleaned = false
|
||||||
|
return {
|
||||||
|
stdin: source,
|
||||||
|
cleanup() {
|
||||||
|
if (cleaned) return
|
||||||
|
cleaned = true
|
||||||
|
source.destroy()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for owner-local resource cleanup tests. */
|
||||||
|
export async function usingInteractiveStdin<T>(
|
||||||
|
run: (terminal: InteractiveStdin) => Promise<T>,
|
||||||
|
resolve: () => InteractiveStdin = resolveInteractiveStdin,
|
||||||
|
) {
|
||||||
|
const terminal = resolve()
|
||||||
|
try {
|
||||||
|
return await run(terminal)
|
||||||
|
} finally {
|
||||||
|
terminal.cleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for owner-local host capability tests. */
|
||||||
|
export function createMiniHost(input: {
|
||||||
|
terminal: InteractiveStdin
|
||||||
|
directory: string
|
||||||
|
paths?: { home: string; state: string; log: string }
|
||||||
|
}): MiniHost {
|
||||||
|
const paths = input.paths ?? {
|
||||||
|
home: Global.Path.home,
|
||||||
|
state: Global.Path.state,
|
||||||
|
log: Global.Path.log,
|
||||||
|
}
|
||||||
|
const diagnostics = {
|
||||||
|
pid: process.pid,
|
||||||
|
cwd: input.directory,
|
||||||
|
argv: process.argv.slice(2),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
terminal: { stdin: input.terminal.stdin },
|
||||||
|
platform: process.platform,
|
||||||
|
stdout: {
|
||||||
|
write(value) {
|
||||||
|
process.stdout.write(value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
readText: (url) => readFile(new URL(url), "utf8"),
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
async open(options) {
|
||||||
|
const { openEditor } = await import("@opencode-ai/tui/editor")
|
||||||
|
return openEditor(options)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paths: { home: paths.home },
|
||||||
|
signals: {
|
||||||
|
sigint: signal("SIGINT"),
|
||||||
|
sigusr2: signal("SIGUSR2"),
|
||||||
|
},
|
||||||
|
startup: {
|
||||||
|
showTiming: Flag.OPENCODE_SHOW_TTFD,
|
||||||
|
now: () => performance.now(),
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
trace: createTrace(paths.log, diagnostics),
|
||||||
|
},
|
||||||
|
preferences: preferences(paths.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
277
packages/cli/src/mini.ts
Normal file
277
packages/cli/src/mini.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
|
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
|
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||||
|
import { setTimeout } from "node:timers/promises"
|
||||||
|
import { waitForCatalogReady } from "./services/catalog"
|
||||||
|
import { readStdin } from "./util/io"
|
||||||
|
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
|
||||||
|
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
|
||||||
|
|
||||||
|
export type MiniCommandInput = {
|
||||||
|
server: {
|
||||||
|
endpoint: Endpoint
|
||||||
|
reconnect?: (signal: AbortSignal) => Promise<Endpoint>
|
||||||
|
}
|
||||||
|
continue?: boolean
|
||||||
|
session?: string
|
||||||
|
fork?: boolean
|
||||||
|
model?: string
|
||||||
|
agent?: string
|
||||||
|
prompt?: string
|
||||||
|
replay?: boolean
|
||||||
|
replayLimit?: number
|
||||||
|
demo?: boolean
|
||||||
|
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||||
|
}
|
||||||
|
|
||||||
|
type Model = MiniFrontendInput["model"]
|
||||||
|
|
||||||
|
class MiniInputError extends Error {}
|
||||||
|
|
||||||
|
export async function runMini(input: MiniCommandInput) {
|
||||||
|
try {
|
||||||
|
validate(input)
|
||||||
|
const result = await usingInteractiveStdin(async (terminal) => {
|
||||||
|
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
|
||||||
|
const frontendTask = import("@opencode-ai/tui/mini")
|
||||||
|
const directory = localDirectory()
|
||||||
|
const connection = createMiniConnection(input.server)
|
||||||
|
const sdk = connection.sdk
|
||||||
|
const requested = parseModel(input.model)
|
||||||
|
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
|
||||||
|
const prepare = prepareTarget(input.agent)
|
||||||
|
const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => {
|
||||||
|
const resolved = await resolveMiniTarget({
|
||||||
|
sdk: initial,
|
||||||
|
reconnect: connection.reconnect,
|
||||||
|
signal,
|
||||||
|
resolve: (client) =>
|
||||||
|
resolveSessionTarget({
|
||||||
|
client,
|
||||||
|
location: { directory },
|
||||||
|
continue: input.continue,
|
||||||
|
session: input.session,
|
||||||
|
fork: input.fork,
|
||||||
|
model: requested,
|
||||||
|
agent: input.agent,
|
||||||
|
prepare,
|
||||||
|
signal,
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error instanceof Error && error.message === "Session not found")
|
||||||
|
throw new MiniInputError(error.message)
|
||||||
|
throw error
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const target = resolved.value
|
||||||
|
return {
|
||||||
|
sdk: resolved.sdk,
|
||||||
|
sessionID: target.session.id,
|
||||||
|
sessionTitle: target.session.title,
|
||||||
|
location: target.location,
|
||||||
|
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
|
||||||
|
variant: target.model?.variant,
|
||||||
|
agent: target.agent,
|
||||||
|
resume: target.resume,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const create = (
|
||||||
|
client: OpenCodeClient,
|
||||||
|
next: {
|
||||||
|
location: { directory: string; workspaceID?: string }
|
||||||
|
agent: string | undefined
|
||||||
|
model: Model
|
||||||
|
variant: string | undefined
|
||||||
|
},
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) =>
|
||||||
|
resolveSessionTarget({
|
||||||
|
client,
|
||||||
|
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
||||||
|
agent: next.agent,
|
||||||
|
model: next.model
|
||||||
|
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
||||||
|
: undefined,
|
||||||
|
prepare,
|
||||||
|
signal,
|
||||||
|
}).then((target) => ({
|
||||||
|
sessionID: target.session.id,
|
||||||
|
sessionTitle: target.session.title,
|
||||||
|
location: target.location,
|
||||||
|
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
|
||||||
|
variant: target.model?.variant,
|
||||||
|
agent: target.agent,
|
||||||
|
resume: false,
|
||||||
|
}))
|
||||||
|
const frontend = await frontendTask
|
||||||
|
return frontend.runMiniFrontend({
|
||||||
|
host: createMiniHost({ terminal, directory }),
|
||||||
|
sdk,
|
||||||
|
directory,
|
||||||
|
target: resolveTarget,
|
||||||
|
reconnect: connection.reconnect,
|
||||||
|
createSession: create,
|
||||||
|
agent: input.agent,
|
||||||
|
model,
|
||||||
|
variant: requested?.variant,
|
||||||
|
files: [],
|
||||||
|
initialInput,
|
||||||
|
replay: input.replay ?? true,
|
||||||
|
replayLimit: input.replayLimit,
|
||||||
|
demo: input.demo,
|
||||||
|
tuiConfig: input.tuiConfig,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (result.exitCode !== 0) process.exit(result.exitCode)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MiniInputError || (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR))
|
||||||
|
fail(error.message)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for CLI boundary tests. */
|
||||||
|
export function createMiniConnection(input: MiniCommandInput["server"]) {
|
||||||
|
const make = (endpoint: Endpoint) =>
|
||||||
|
OpenCode.make({
|
||||||
|
baseUrl: endpoint.url,
|
||||||
|
headers: Service.headers(endpoint),
|
||||||
|
})
|
||||||
|
const reconnect = input.reconnect
|
||||||
|
return {
|
||||||
|
sdk: make(input.endpoint),
|
||||||
|
reconnect: reconnect
|
||||||
|
? async (signal: AbortSignal) => {
|
||||||
|
const endpoint = await reconnect(signal)
|
||||||
|
return make(endpoint)
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for reconnect lifecycle tests. */
|
||||||
|
export async function resolveMiniTarget<A>(input: {
|
||||||
|
sdk: OpenCodeClient
|
||||||
|
reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
|
||||||
|
signal: AbortSignal
|
||||||
|
resolve: (sdk: OpenCodeClient) => Promise<A>
|
||||||
|
}) {
|
||||||
|
let sdk = input.sdk
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return { sdk, value: await input.resolve(sdk) }
|
||||||
|
} catch (error) {
|
||||||
|
if (!input.reconnect || !(error instanceof ClientError) || error.reason !== "Transport") throw error
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
sdk = await input.reconnect(input.signal)
|
||||||
|
break
|
||||||
|
} catch (resolveError) {
|
||||||
|
if (input.signal.aborted) throw resolveError
|
||||||
|
await setTimeout(250, undefined, { signal: input.signal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateMiniTerminal() {
|
||||||
|
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for testing. */
|
||||||
|
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
|
||||||
|
if (!prompt) return piped || undefined
|
||||||
|
if (!piped) return prompt
|
||||||
|
return piped + "\n" + prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate(input: MiniCommandInput) {
|
||||||
|
validateMiniTerminal()
|
||||||
|
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
|
||||||
|
fail("--replay-limit must be a positive integer")
|
||||||
|
}
|
||||||
|
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDirectory(): string {
|
||||||
|
const root = process.env.PWD ?? process.cwd()
|
||||||
|
try {
|
||||||
|
process.chdir(root)
|
||||||
|
return process.cwd()
|
||||||
|
} catch {
|
||||||
|
throw new MiniInputError(`Failed to change directory to ${root}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModel(value?: string) {
|
||||||
|
try {
|
||||||
|
return parseSessionTargetModel(value)
|
||||||
|
} catch {
|
||||||
|
throw new MiniInputError("--model must use the format provider/model[#variant]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
|
||||||
|
return async (input) => {
|
||||||
|
if (input.model)
|
||||||
|
await waitForCatalogReady({
|
||||||
|
sdk: input.client,
|
||||||
|
directory: input.location.directory,
|
||||||
|
workspace: input.location.workspaceID,
|
||||||
|
model: { providerID: input.model.providerID, modelID: input.model.id },
|
||||||
|
signal: input.signal,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
model: input.model,
|
||||||
|
agent: requestedAgent
|
||||||
|
? await validateAgent(
|
||||||
|
input.client,
|
||||||
|
input.location.directory,
|
||||||
|
input.location.workspaceID,
|
||||||
|
requestedAgent,
|
||||||
|
input.signal,
|
||||||
|
)
|
||||||
|
: input.agent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateAgent(
|
||||||
|
sdk: OpenCodeClient,
|
||||||
|
directory: string,
|
||||||
|
workspace: string | undefined,
|
||||||
|
name?: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) {
|
||||||
|
if (!name) return
|
||||||
|
const deadline = Date.now() + 5_000
|
||||||
|
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
||||||
|
while (Date.now() < deadline && !signal?.aborted) {
|
||||||
|
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
|
||||||
|
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
const agent = agents?.data.find((item) => item.id === name)
|
||||||
|
if (agent?.mode === "subagent") {
|
||||||
|
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (agent) return name
|
||||||
|
await setTimeout(25, undefined, { signal }).catch(() => {})
|
||||||
|
}
|
||||||
|
if (signal?.aborted) return
|
||||||
|
if (!agents) {
|
||||||
|
warning("failed to list agents. Falling back to default agent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function warning(message: string) {
|
||||||
|
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import type {
|
|
||||||
AgentListOutput,
|
|
||||||
CommandListOutput,
|
|
||||||
ModelListOutput,
|
|
||||||
OpenCodeClient,
|
|
||||||
ProviderListOutput,
|
|
||||||
SkillListOutput,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
|
|
||||||
|
|
||||||
type CurrentAgent = AgentListOutput["data"][number]
|
|
||||||
type CurrentCommand = CommandListOutput["data"][number]
|
|
||||||
type CurrentSkill = SkillListOutput["data"][number]
|
|
||||||
type CurrentProvider = ProviderListOutput["data"][number]
|
|
||||||
type CurrentModel = ModelListOutput["data"][number]
|
|
||||||
|
|
||||||
function location(directory: string, workspace?: string) {
|
|
||||||
return {
|
|
||||||
location: {
|
|
||||||
directory,
|
|
||||||
workspace,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultCost(model: CurrentModel) {
|
|
||||||
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
|
|
||||||
if (!picked) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...picked,
|
|
||||||
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runAgent(input: CurrentAgent): RunAgent {
|
|
||||||
return {
|
|
||||||
id: input.id,
|
|
||||||
name: input.name,
|
|
||||||
description: input.description,
|
|
||||||
mode: input.mode,
|
|
||||||
hidden: input.hidden,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runCommand(input: CurrentCommand): RunCommand {
|
|
||||||
return {
|
|
||||||
name: input.name,
|
|
||||||
description: input.description,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runSkill(input: CurrentSkill): RunCommand {
|
|
||||||
return {
|
|
||||||
name: input.id,
|
|
||||||
description: input.description,
|
|
||||||
source: "skill",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
|
|
||||||
const grouped = new Map<string, RunProvider>()
|
|
||||||
|
|
||||||
for (const provider of providers) {
|
|
||||||
grouped.set(provider.id, {
|
|
||||||
id: provider.id,
|
|
||||||
name: provider.name,
|
|
||||||
models: {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const model of models) {
|
|
||||||
const provider = grouped.get(model.providerID) ?? {
|
|
||||||
id: model.providerID,
|
|
||||||
name: model.providerID,
|
|
||||||
models: {},
|
|
||||||
}
|
|
||||||
provider.models[model.id] = {
|
|
||||||
id: model.id,
|
|
||||||
providerID: model.providerID,
|
|
||||||
name: model.name,
|
|
||||||
capabilities: model.capabilities,
|
|
||||||
cost: defaultCost(model),
|
|
||||||
limit: model.limit,
|
|
||||||
status: model.status,
|
|
||||||
variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])),
|
|
||||||
}
|
|
||||||
grouped.set(provider.id, provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...grouped.values()]
|
|
||||||
}
|
|
||||||
|
|
||||||
// A location boots its plugins in a deferred background batch after the layer
|
|
||||||
// is built, so first-turn model resolution can observe empty catalog state.
|
|
||||||
// For explicit --model flows, wait for that exact ref to appear before prompt
|
|
||||||
// admission. On timeout, return and let the real execution error surface.
|
|
||||||
export async function waitForCatalogReady(input: {
|
|
||||||
sdk: OpenCodeClient
|
|
||||||
directory: string
|
|
||||||
workspace?: string
|
|
||||||
model: { providerID: string; modelID: string }
|
|
||||||
timeoutMs?: number
|
|
||||||
}) {
|
|
||||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const models = await input.sdk.model
|
|
||||||
.list(location(input.directory, input.workspace))
|
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForDefaultModel(input: {
|
|
||||||
sdk: OpenCodeClient
|
|
||||||
directory: string
|
|
||||||
timeoutMs?: number
|
|
||||||
active?: () => boolean
|
|
||||||
}): Promise<{ providerID: string; modelID: string } | undefined> {
|
|
||||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
|
||||||
while (Date.now() < deadline && (input.active?.() ?? true)) {
|
|
||||||
const model = await input.sdk.model
|
|
||||||
.default(location(input.directory))
|
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (model) return { providerID: model.providerID, modelID: model.id }
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
|
|
||||||
const result = await sdk.agent.list(location(directory))
|
|
||||||
return result.data.map(runAgent)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise<RunCommand[]> {
|
|
||||||
const [commands, skills] = await Promise.all([
|
|
||||||
sdk.command.list(location(directory)),
|
|
||||||
sdk.skill.list(location(directory)),
|
|
||||||
])
|
|
||||||
return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
|
|
||||||
const result = await sdk.reference.list(location(directory))
|
|
||||||
return result.data.filter((reference) => !reference.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise<RunProvider[]> {
|
|
||||||
const [providers, models] = await Promise.all([
|
|
||||||
sdk.provider.list(location(directory)),
|
|
||||||
sdk.model.list(location(directory)),
|
|
||||||
])
|
|
||||||
return runProviders([...providers.data], [...models.data])
|
|
||||||
}
|
|
||||||
|
|
@ -1,573 +0,0 @@
|
||||||
// Question UI body for the direct-mode footer.
|
|
||||||
//
|
|
||||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
|
||||||
// "question". Supports single-question and multi-question flows:
|
|
||||||
//
|
|
||||||
// Single question: options list with up/down selection, digit shortcuts,
|
|
||||||
// and optional custom text input.
|
|
||||||
//
|
|
||||||
// Multi-question: tabbed interface where each question is a tab, plus a
|
|
||||||
// final "Confirm" tab that shows all answers for review. Tab/shift-tab
|
|
||||||
// or left/right to navigate between questions.
|
|
||||||
//
|
|
||||||
// All state logic lives in question.shared.ts as a pure state machine.
|
|
||||||
// This component just renders it and dispatches keyboard events.
|
|
||||||
/** @jsxImportSource @opentui/solid */
|
|
||||||
import type { TextareaRenderable } from "@opentui/core"
|
|
||||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
|
||||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
|
||||||
import type { QuestionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import {
|
|
||||||
createQuestionBodyState,
|
|
||||||
questionConfirm,
|
|
||||||
questionCustom,
|
|
||||||
questionInfo,
|
|
||||||
questionInput,
|
|
||||||
questionMove,
|
|
||||||
questionOther,
|
|
||||||
questionPicked,
|
|
||||||
questionReject,
|
|
||||||
questionSave,
|
|
||||||
questionSelect,
|
|
||||||
questionSetEditing,
|
|
||||||
questionSetSelected,
|
|
||||||
questionSetSubmitting,
|
|
||||||
questionSetTab,
|
|
||||||
questionSingle,
|
|
||||||
questionStoreCustom,
|
|
||||||
questionSubmit,
|
|
||||||
questionSync,
|
|
||||||
questionTabs,
|
|
||||||
questionTotal,
|
|
||||||
} from "./question.shared"
|
|
||||||
import { footerWidthPolicy } from "./footer.width"
|
|
||||||
import type { RunFooterTheme } from "./theme"
|
|
||||||
import type { QuestionReject, QuestionReply } from "./types"
|
|
||||||
|
|
||||||
export function RunQuestionBody(props: {
|
|
||||||
request: QuestionV2Request
|
|
||||||
theme: RunFooterTheme
|
|
||||||
onReply: (input: QuestionReply) => void | Promise<void>
|
|
||||||
onReject: (input: QuestionReject) => void | Promise<void>
|
|
||||||
}) {
|
|
||||||
const dims = useTerminalDimensions()
|
|
||||||
const [state, setState] = createSignal(createQuestionBodyState(props.request.id))
|
|
||||||
const single = createMemo(() => questionSingle(props.request))
|
|
||||||
const confirm = createMemo(() => questionConfirm(props.request, state()))
|
|
||||||
const info = createMemo(() => questionInfo(props.request, state()))
|
|
||||||
const input = createMemo(() => questionInput(state()))
|
|
||||||
const other = createMemo(() => questionOther(props.request, state()))
|
|
||||||
const picked = createMemo(() => questionPicked(state()))
|
|
||||||
const disabled = createMemo(() => state().submitting)
|
|
||||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
|
||||||
const verb = createMemo(() => {
|
|
||||||
if (confirm()) {
|
|
||||||
return "submit"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info()?.multiple) {
|
|
||||||
return "toggle"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (single()) {
|
|
||||||
return "submit"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "confirm"
|
|
||||||
})
|
|
||||||
let area: TextareaRenderable | undefined
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
setState((prev) => questionSync(prev, props.request.id))
|
|
||||||
})
|
|
||||||
|
|
||||||
const setTab = (tab: number) => {
|
|
||||||
setState((prev) => questionSetTab(prev, tab))
|
|
||||||
}
|
|
||||||
|
|
||||||
const move = (dir: -1 | 1) => {
|
|
||||||
setState((prev) => questionMove(prev, props.request, dir))
|
|
||||||
}
|
|
||||||
|
|
||||||
const beginReply = async (input: QuestionReply) => {
|
|
||||||
setState((prev) => questionSetSubmitting(prev, true))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await props.onReply(input)
|
|
||||||
} catch {
|
|
||||||
setState((prev) => questionSetSubmitting(prev, false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const beginReject = async (input: QuestionReject) => {
|
|
||||||
setState((prev) => questionSetSubmitting(prev, true))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await props.onReject(input)
|
|
||||||
} catch {
|
|
||||||
setState((prev) => questionSetSubmitting(prev, false))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveCustom = () => {
|
|
||||||
const cur = state()
|
|
||||||
const next = questionSave(cur, props.request)
|
|
||||||
if (next.state !== cur) {
|
|
||||||
setState(next.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!next.reply) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void beginReply(next.reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
const choose = (selected: number) => {
|
|
||||||
const base = state()
|
|
||||||
const cur = questionSetSelected(base, selected)
|
|
||||||
const next = questionSelect(cur, props.request)
|
|
||||||
if (next.state !== base) {
|
|
||||||
setState(next.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!next.reply) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void beginReply(next.reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mark = (selected: number) => {
|
|
||||||
setState((prev) => questionSetSelected(prev, selected))
|
|
||||||
}
|
|
||||||
|
|
||||||
const select = () => {
|
|
||||||
const cur = state()
|
|
||||||
const next = questionSelect(cur, props.request)
|
|
||||||
if (next.state !== cur) {
|
|
||||||
setState(next.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!next.reply) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void beginReply(next.reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
void beginReply(questionSubmit(props.request, state()))
|
|
||||||
}
|
|
||||||
|
|
||||||
const reject = () => {
|
|
||||||
void beginReject(questionReject(props.request))
|
|
||||||
}
|
|
||||||
|
|
||||||
useKeyboard((event) => {
|
|
||||||
const cur = state()
|
|
||||||
if (cur.submitting) {
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cur.editing) {
|
|
||||||
if (event.name === "escape") {
|
|
||||||
setState((prev) => questionSetEditing(prev, false))
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!single() && (event.name === "left" || event.name === "h")) {
|
|
||||||
setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request))
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!single() && (event.name === "right" || event.name === "l")) {
|
|
||||||
setTab((cur.tab + 1) % questionTabs(props.request))
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!single() && event.name === "tab") {
|
|
||||||
const dir = event.shift ? -1 : 1
|
|
||||||
setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request))
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionConfirm(props.request, cur)) {
|
|
||||||
if (event.name === "return") {
|
|
||||||
submit()
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "escape") {
|
|
||||||
reject()
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = questionTotal(props.request, cur)
|
|
||||||
const max = Math.min(total, 9)
|
|
||||||
const digit = Number(event.name)
|
|
||||||
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
|
|
||||||
choose(digit - 1)
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "up" || event.name === "k") {
|
|
||||||
move(-1)
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "down" || event.name === "j") {
|
|
||||||
move(1)
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "return") {
|
|
||||||
select()
|
|
||||||
event.preventDefault()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.name === "escape") {
|
|
||||||
reject()
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!state().editing || !area || area.isDestroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (area.plainText !== input()) {
|
|
||||||
area.setText(input())
|
|
||||||
area.cursorOffset = input().length
|
|
||||||
}
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (!area || area.isDestroyed || !state().editing) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
area.focus()
|
|
||||||
area.cursorOffset = area.plainText.length
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box width="100%" height="100%" flexDirection="column">
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={3}
|
|
||||||
paddingTop={1}
|
|
||||||
flexGrow={1}
|
|
||||||
flexShrink={1}
|
|
||||||
backgroundColor={props.theme.surface}
|
|
||||||
>
|
|
||||||
<Show when={!single()}>
|
|
||||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
|
||||||
<For each={props.request.questions}>
|
|
||||||
{(item, index) => {
|
|
||||||
const active = () => state().tab === index()
|
|
||||||
const answered = () => (state().answers[index()]?.length ?? 0) > 0
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (!disabled()) setTab(index())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
|
|
||||||
{item.header}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
<box
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (!disabled()) setTab(props.request.questions.length)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={!confirm()}
|
|
||||||
fallback={
|
|
||||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
|
|
||||||
<scrollbox
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
verticalScrollbarOptions={{
|
|
||||||
trackOptions: {
|
|
||||||
backgroundColor: props.theme.surface,
|
|
||||||
foregroundColor: props.theme.line,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box width="100%" flexDirection="column" gap={1}>
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
<text fg={props.theme.text}>Review</text>
|
|
||||||
</box>
|
|
||||||
<For each={props.request.questions}>
|
|
||||||
{(item, index) => {
|
|
||||||
const value = () => state().answers[index()]?.join(", ") ?? ""
|
|
||||||
const answered = () => Boolean(value())
|
|
||||||
return (
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
<text wrapMode="word">
|
|
||||||
<span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
|
|
||||||
<span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
|
|
||||||
{answered() ? value() : "(not answered)"}
|
|
||||||
</span>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
|
|
||||||
<box>
|
|
||||||
<text fg={props.theme.text} wrapMode="word">
|
|
||||||
{info()?.question}
|
|
||||||
{info()?.multiple ? " (select all that apply)" : ""}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexGrow={1} flexShrink={1}>
|
|
||||||
<scrollbox
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
verticalScrollbarOptions={{
|
|
||||||
trackOptions: {
|
|
||||||
backgroundColor: props.theme.surface,
|
|
||||||
foregroundColor: props.theme.line,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box width="100%" flexDirection="column">
|
|
||||||
<For each={info()?.options ?? []}>
|
|
||||||
{(item, index) => {
|
|
||||||
const active = () => state().selected === index()
|
|
||||||
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
onMouseOver={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
mark(index())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseDown={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
mark(index())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
choose(index())
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box flexDirection="row">
|
|
||||||
<box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
|
|
||||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
|
|
||||||
</box>
|
|
||||||
<box backgroundColor={active() ? props.theme.line : undefined}>
|
|
||||||
<text
|
|
||||||
fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
|
|
||||||
>
|
|
||||||
{info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show when={!info()?.multiple}>
|
|
||||||
<text fg={props.theme.success}>{hit() ? " ✓" : ""}</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text fg={props.theme.muted} wrapMode="word">
|
|
||||||
{item.description}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
<Show when={questionCustom(props.request, state())}>
|
|
||||||
<box
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
onMouseOver={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
mark(info()?.options.length ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseDown={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
mark(info()?.options.length ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseUp={() => {
|
|
||||||
if (!disabled()) {
|
|
||||||
choose(info()?.options.length ?? 0)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box flexDirection="row">
|
|
||||||
<box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
|
|
||||||
<text
|
|
||||||
fg={other() ? props.theme.highlight : props.theme.muted}
|
|
||||||
>{`${(info()?.options.length ?? 0) + 1}.`}</text>
|
|
||||||
</box>
|
|
||||||
<box backgroundColor={other() ? props.theme.line : undefined}>
|
|
||||||
<text
|
|
||||||
fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
|
|
||||||
>
|
|
||||||
{info()?.multiple
|
|
||||||
? `[${picked() ? "✓" : " "}] Type your own answer`
|
|
||||||
: "Type your own answer"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<Show when={!info()?.multiple}>
|
|
||||||
<text fg={props.theme.success}>{picked() ? " ✓" : ""}</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<Show
|
|
||||||
when={state().editing}
|
|
||||||
fallback={
|
|
||||||
<Show when={input()}>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text fg={props.theme.muted} wrapMode="word">
|
|
||||||
{input()}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<textarea
|
|
||||||
width="100%"
|
|
||||||
minHeight={1}
|
|
||||||
maxHeight={4}
|
|
||||||
wrapMode="word"
|
|
||||||
placeholder="Type your own answer"
|
|
||||||
placeholderColor={props.theme.muted}
|
|
||||||
textColor={props.theme.text}
|
|
||||||
focusedTextColor={props.theme.text}
|
|
||||||
backgroundColor={props.theme.surface}
|
|
||||||
focusedBackgroundColor={props.theme.surface}
|
|
||||||
cursorColor={props.theme.text}
|
|
||||||
focused={!disabled()}
|
|
||||||
onSubmit={saveCustom}
|
|
||||||
onContentChange={() => {
|
|
||||||
if (!area || area.isDestroyed || disabled()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = area.plainText
|
|
||||||
setState((prev) => questionStoreCustom(prev, prev.tab, text))
|
|
||||||
}}
|
|
||||||
ref={(item) => {
|
|
||||||
area = item
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
flexDirection={narrow() ? "column" : "row"}
|
|
||||||
flexShrink={0}
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
paddingRight={3}
|
|
||||||
paddingBottom={1}
|
|
||||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
|
||||||
alignItems={narrow() ? "flex-start" : "center"}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={!disabled()}
|
|
||||||
fallback={
|
|
||||||
<text fg={props.theme.muted} wrapMode="word">
|
|
||||||
Waiting for question event...
|
|
||||||
</text>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
flexDirection={narrow() ? "column" : "row"}
|
|
||||||
gap={narrow() ? 1 : 2}
|
|
||||||
flexShrink={0}
|
|
||||||
width={narrow() ? "100%" : undefined}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={!state().editing}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
enter <span style={{ fg: props.theme.muted }}>save</span>
|
|
||||||
</text>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
|
||||||
</text>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show when={!single()}>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
{"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={!confirm()}>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
{"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
|
|
||||||
</text>
|
|
||||||
<text fg={props.theme.text}>
|
|
||||||
esc <span style={{ fg: props.theme.muted }}>dismiss</span>
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
export { runMini, validateMiniTerminal, mergeInput as mergeInteractiveInput, type MiniCommandInput } from "./mini"
|
|
||||||
export {
|
|
||||||
runNonInteractive,
|
|
||||||
mergeInput as mergeNonInteractiveInput,
|
|
||||||
pickRunModel,
|
|
||||||
parseRunModel,
|
|
||||||
type RunCommandInput,
|
|
||||||
} from "./run"
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
import { Service } from "@opencode-ai/client/effect/service"
|
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
|
||||||
import { ServerConnection } from "../services/server-connection"
|
|
||||||
import { waitForCatalogReady } from "./catalog.shared"
|
|
||||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
|
||||||
import type { RunInput, RunTuiConfig } from "./types"
|
|
||||||
|
|
||||||
export type MiniCommandInput = {
|
|
||||||
server: ServerConnection.Resolved
|
|
||||||
continue?: boolean
|
|
||||||
session?: string
|
|
||||||
fork?: boolean
|
|
||||||
model?: string
|
|
||||||
agent?: string
|
|
||||||
prompt?: string
|
|
||||||
replay?: boolean
|
|
||||||
replayLimit?: number
|
|
||||||
demo?: boolean
|
|
||||||
tuiConfig?: RunTuiConfig | Promise<RunTuiConfig>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Session = Awaited<ReturnType<OpenCodeClient["session"]["get"]>>
|
|
||||||
export async function runMini(input: MiniCommandInput) {
|
|
||||||
validate(input)
|
|
||||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await Bun.stdin.text(), input.prompt)
|
|
||||||
const runtimeTask = import("./runtime")
|
|
||||||
const directory = localDirectory()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sdk = OpenCode.make({
|
|
||||||
baseUrl: input.server.endpoint.url,
|
|
||||||
headers: Service.headers(input.server.endpoint),
|
|
||||||
})
|
|
||||||
const model = parseModel(input.model)
|
|
||||||
let agentTask: Promise<string | undefined> | undefined
|
|
||||||
const resolveAgent = () => {
|
|
||||||
agentTask ??= validateAgent(sdk, directory, input.agent)
|
|
||||||
return agentTask
|
|
||||||
}
|
|
||||||
const resolveSession = async () => {
|
|
||||||
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
|
|
||||||
const readyModel =
|
|
||||||
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
|
||||||
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
|
|
||||||
const session = selected ?? (await createSession(sdk, directory, agent, model))
|
|
||||||
return { id: session.id, title: session.title, resume: selected !== undefined }
|
|
||||||
}
|
|
||||||
const create = (
|
|
||||||
_sdk: OpenCodeClient,
|
|
||||||
next: { agent: string | undefined; model: RunInput["model"]; variant: string | undefined },
|
|
||||||
) => createSession(sdk, directory, next.agent, next.model, next.variant)
|
|
||||||
const runtime = await runtimeTask
|
|
||||||
await runtime.runInteractiveDeferredMode({
|
|
||||||
sdk,
|
|
||||||
directory,
|
|
||||||
resolveAgent,
|
|
||||||
session: resolveSession,
|
|
||||||
createSession: create,
|
|
||||||
agent: input.agent,
|
|
||||||
model,
|
|
||||||
variant: undefined,
|
|
||||||
files: [],
|
|
||||||
initialInput,
|
|
||||||
thinking: true,
|
|
||||||
replay: input.replay ?? true,
|
|
||||||
replayLimit: input.replayLimit,
|
|
||||||
demo: input.demo,
|
|
||||||
tuiConfig: input.tuiConfig,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR) fail(error.message)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateMiniTerminal() {
|
|
||||||
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal Exported for testing. */
|
|
||||||
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
|
|
||||||
if (!prompt) return piped || undefined
|
|
||||||
if (!piped) return prompt
|
|
||||||
return piped + "\n" + prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate(input: MiniCommandInput) {
|
|
||||||
validateMiniTerminal()
|
|
||||||
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
|
|
||||||
fail("--replay-limit must be a positive integer")
|
|
||||||
}
|
|
||||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
|
||||||
resolveInteractiveStdin().cleanup?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
function localDirectory(): string {
|
|
||||||
const root = process.env.PWD ?? process.cwd()
|
|
||||||
try {
|
|
||||||
process.chdir(root)
|
|
||||||
return process.cwd()
|
|
||||||
} catch {
|
|
||||||
fail(`Failed to change directory to ${root}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseModel(value?: string): RunInput["model"] {
|
|
||||||
if (!value) return
|
|
||||||
const [providerID, ...rest] = value.split("/")
|
|
||||||
const modelID = rest.join("/")
|
|
||||||
if (!providerID || !modelID) fail("--model must use the format provider/model")
|
|
||||||
return { providerID, modelID }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateAgent(sdk: OpenCodeClient, directory: string, name?: string) {
|
|
||||||
if (!name) return
|
|
||||||
const deadline = Date.now() + 5_000
|
|
||||||
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
agents = await sdk.agent.list({ location: { directory } }).catch(() => undefined)
|
|
||||||
const agent = agents?.data.find((item) => item.id === name)
|
|
||||||
if (agent?.mode === "subagent") {
|
|
||||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (agent) return name
|
|
||||||
await Bun.sleep(25)
|
|
||||||
}
|
|
||||||
if (!agents) {
|
|
||||||
warning("failed to list agents. Falling back to default agent")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectSession(sdk: OpenCodeClient, directory: string, input: MiniCommandInput, preselected?: Session) {
|
|
||||||
const selected =
|
|
||||||
preselected ??
|
|
||||||
(input.session
|
|
||||||
? await sdk.session.get({ sessionID: input.session }).catch(() => undefined)
|
|
||||||
: input.continue
|
|
||||||
? await sdk.session
|
|
||||||
.list({ directory, parentID: null, limit: 1, order: "desc" })
|
|
||||||
.then((result) => result.data[0])
|
|
||||||
: undefined)
|
|
||||||
if (input.session && !selected) fail("Session not found")
|
|
||||||
if (!selected) return
|
|
||||||
if (!input.fork) return selected
|
|
||||||
return sdk.session.fork({ sessionID: selected.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSession(
|
|
||||||
sdk: OpenCodeClient,
|
|
||||||
directory: string,
|
|
||||||
agent: string | undefined,
|
|
||||||
model: RunInput["model"],
|
|
||||||
variant?: string,
|
|
||||||
): Promise<Session> {
|
|
||||||
if (model) await waitForCatalogReady({ sdk, directory, model })
|
|
||||||
return sdk.session.create({
|
|
||||||
agent,
|
|
||||||
model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined,
|
|
||||||
location: { directory },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function warning(message: string) {
|
|
||||||
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail(message: string): never {
|
|
||||||
process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
// Pure state machine for the permission UI.
|
|
||||||
//
|
|
||||||
// Lives outside the JSX component so it can be tested independently. The
|
|
||||||
// machine has three stages:
|
|
||||||
//
|
|
||||||
// permission → initial view with Allow once / Always / Reject options
|
|
||||||
// always → confirmation step (Confirm / Cancel)
|
|
||||||
// reject → text input for rejection message
|
|
||||||
//
|
|
||||||
// permissionRun() is the main transition: given the current state and the
|
|
||||||
// selected option, it returns a new state and optionally a PermissionReply
|
|
||||||
// to send to the SDK. The component calls this on enter/click.
|
|
||||||
//
|
|
||||||
// permissionInfo() extracts display info (icon, title, lines, diff) from
|
|
||||||
// the request, delegating to tool.ts for tool-specific formatting.
|
|
||||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import type { PermissionReply } from "./types"
|
|
||||||
import { toolPath, toolPermissionInfo } from "./tool"
|
|
||||||
|
|
||||||
type Dict = Record<string, unknown>
|
|
||||||
|
|
||||||
export type PermissionStage = "permission" | "always" | "reject"
|
|
||||||
export type PermissionOption = "once" | "always" | "reject" | "confirm" | "cancel"
|
|
||||||
|
|
||||||
export type PermissionBodyState = {
|
|
||||||
requestID: string
|
|
||||||
stage: PermissionStage
|
|
||||||
selected: PermissionOption
|
|
||||||
message: string
|
|
||||||
submitting: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PermissionInfo = {
|
|
||||||
icon: string
|
|
||||||
title: string
|
|
||||||
lines: string[]
|
|
||||||
diff?: string
|
|
||||||
file?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PermissionStep = {
|
|
||||||
state: PermissionBodyState
|
|
||||||
reply?: PermissionReply
|
|
||||||
}
|
|
||||||
|
|
||||||
function dict(v: unknown): Dict {
|
|
||||||
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...v }
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(v: unknown): string {
|
|
||||||
return typeof v === "string" ? v : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function data(request: PermissionV2Request): Dict {
|
|
||||||
const meta = dict(request.metadata)
|
|
||||||
return {
|
|
||||||
...meta,
|
|
||||||
...dict(meta.input),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function patterns(request: PermissionV2Request): string[] {
|
|
||||||
return request.resources.filter((item): item is string => typeof item === "string")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPermissionBodyState(requestID: string): PermissionBodyState {
|
|
||||||
return {
|
|
||||||
requestID,
|
|
||||||
stage: "permission",
|
|
||||||
selected: "once",
|
|
||||||
message: "",
|
|
||||||
submitting: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionOptions(stage: PermissionStage): PermissionOption[] {
|
|
||||||
if (stage === "permission") {
|
|
||||||
return ["once", "always", "reject"]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stage === "always") {
|
|
||||||
return ["confirm", "cancel"]
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionInfo(request: PermissionV2Request): PermissionInfo {
|
|
||||||
const pats = patterns(request)
|
|
||||||
const input = data(request)
|
|
||||||
const info = toolPermissionInfo(request.action, input, dict(request.metadata), pats)
|
|
||||||
if (info) {
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.action === "external_directory") {
|
|
||||||
const meta = dict(request.metadata)
|
|
||||||
const raw = text(meta.parentDir) || text(meta.filepath) || pats[0] || ""
|
|
||||||
const dir = raw.includes("*") ? raw.slice(0, raw.indexOf("*")).replace(/[\\/]+$/, "") : raw
|
|
||||||
return {
|
|
||||||
icon: "←",
|
|
||||||
title: `Access external directory ${toolPath(dir, { home: true })}`,
|
|
||||||
lines: pats.map((item) => `- ${item}`),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.action === "doom_loop") {
|
|
||||||
return {
|
|
||||||
icon: "⟳",
|
|
||||||
title: "Continue after repeated failures",
|
|
||||||
lines: ["This keeps the session running despite repeated failures."],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
icon: "⚙",
|
|
||||||
title: `Call tool ${request.action}`,
|
|
||||||
lines: [`Tool: ${request.action}`],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionAlwaysLines(request: PermissionV2Request): string[] {
|
|
||||||
const save = request.save ?? []
|
|
||||||
if (save.length === 1 && save[0] === "*") {
|
|
||||||
return [`This will allow ${request.action} until OpenCode is restarted.`]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
"This will allow the following patterns until OpenCode is restarted.",
|
|
||||||
...save.map((item) => `- ${item}`),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionLabel(option: PermissionOption): string {
|
|
||||||
if (option === "once") return "Allow once"
|
|
||||||
if (option === "always") return "Allow always"
|
|
||||||
if (option === "reject") return "Reject"
|
|
||||||
if (option === "confirm") return "Confirm"
|
|
||||||
return "Cancel"
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionReply(requestID: string, reply: PermissionReply["reply"], message?: string): PermissionReply {
|
|
||||||
return {
|
|
||||||
requestID,
|
|
||||||
reply,
|
|
||||||
...(message && message.trim() ? { message: message.trim() } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionShift(
|
|
||||||
state: PermissionBodyState,
|
|
||||||
dir: -1 | 1,
|
|
||||||
list = permissionOptions(state.stage),
|
|
||||||
): PermissionBodyState {
|
|
||||||
if (list.length === 0) {
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
const idx = Math.max(0, list.indexOf(state.selected))
|
|
||||||
const selected = list[(idx + dir + list.length) % list.length]
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
selected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionHover(state: PermissionBodyState, option: PermissionOption): PermissionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
selected: option,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionRun(state: PermissionBodyState, requestID: string, option: PermissionOption): PermissionStep {
|
|
||||||
if (state.submitting) {
|
|
||||||
return { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.stage === "permission") {
|
|
||||||
if (option === "always") {
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
...state,
|
|
||||||
stage: "always",
|
|
||||||
selected: "confirm",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option === "reject") {
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
...state,
|
|
||||||
stage: "reject",
|
|
||||||
selected: "reject",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
reply: permissionReply(requestID, "once"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.stage !== "always") {
|
|
||||||
return { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option === "cancel") {
|
|
||||||
return {
|
|
||||||
state: {
|
|
||||||
...state,
|
|
||||||
stage: "permission",
|
|
||||||
selected: "always",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
reply: permissionReply(requestID, "always"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionReject(state: PermissionBodyState, requestID: string): PermissionReply | undefined {
|
|
||||||
if (state.submitting) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return permissionReply(requestID, "reject", state.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionCancel(state: PermissionBodyState): PermissionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stage: "permission",
|
|
||||||
selected: "reject",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function permissionEscape(state: PermissionBodyState): PermissionBodyState {
|
|
||||||
if (state.stage === "always") {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stage: "permission",
|
|
||||||
selected: "always",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
stage: "reject",
|
|
||||||
selected: "reject",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import type { RunPromptPart } from "./types"
|
|
||||||
|
|
||||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
|
||||||
|
|
||||||
export function resolveEditorSlashValue(text: string) {
|
|
||||||
const head = slashHead(text)
|
|
||||||
if (!head || head.name.toLowerCase() !== "editor") {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
return head.arguments
|
|
||||||
}
|
|
||||||
|
|
||||||
export function realignEditorPromptParts(content: string, parts: RunPromptPart[]): RunPromptPart[] {
|
|
||||||
const matches = new Map<number, Mention | undefined>()
|
|
||||||
const used: Array<{ start: number; end: number }> = []
|
|
||||||
|
|
||||||
for (const [index, part] of parts.entries()) {
|
|
||||||
if (part.type !== "file" && part.type !== "agent") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = promptPartText(part)
|
|
||||||
if (!text) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = findPromptPartIndex(content, text, used, promptPartStart(part))
|
|
||||||
if (start === -1) {
|
|
||||||
matches.set(index, undefined)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = start + text.length
|
|
||||||
used.push({ start, end })
|
|
||||||
matches.set(index, updatePromptPart(part, start, end, text))
|
|
||||||
}
|
|
||||||
|
|
||||||
const next: RunPromptPart[] = []
|
|
||||||
for (const [index, part] of parts.entries()) {
|
|
||||||
if (part.type !== "file" && part.type !== "agent") {
|
|
||||||
next.push(part)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!promptPartText(part)) {
|
|
||||||
next.push(part)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = matches.get(index)
|
|
||||||
if (match) {
|
|
||||||
next.push(match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
function slashHead(text: string) {
|
|
||||||
if (!text.startsWith("/")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 1; i < text.length; i++) {
|
|
||||||
switch (text[i]) {
|
|
||||||
case " ":
|
|
||||||
case "\t":
|
|
||||||
case "\n":
|
|
||||||
return {
|
|
||||||
name: text.slice(1, i),
|
|
||||||
arguments: text.slice(i + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: text.slice(1),
|
|
||||||
arguments: "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptPartText(part: Mention) {
|
|
||||||
if (part.type === "agent") {
|
|
||||||
return part.source?.value
|
|
||||||
}
|
|
||||||
|
|
||||||
return part.source?.text.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptPartStart(part: Mention) {
|
|
||||||
if (part.type === "agent") {
|
|
||||||
return part.source?.start ?? Number.POSITIVE_INFINITY
|
|
||||||
}
|
|
||||||
|
|
||||||
return part.source?.text.start ?? Number.POSITIVE_INFINITY
|
|
||||||
}
|
|
||||||
|
|
||||||
function findPromptPartIndex(content: string, text: string, used: Array<{ start: number; end: number }>, hint: number) {
|
|
||||||
let searchFrom = 0
|
|
||||||
let best = -1
|
|
||||||
let distance = Number.POSITIVE_INFINITY
|
|
||||||
const hinted = Number.isFinite(hint)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const start = content.indexOf(text, searchFrom)
|
|
||||||
if (start === -1) {
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = start + text.length
|
|
||||||
searchFrom = start + 1
|
|
||||||
if (used.some((range) => start < range.end && end > range.start)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hinted) {
|
|
||||||
return start
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextDistance = Math.abs(start - hint)
|
|
||||||
if (nextDistance < distance) {
|
|
||||||
best = start
|
|
||||||
distance = nextDistance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePromptPart(part: Mention, start: number, end: number, text: string): Mention {
|
|
||||||
if (part.type === "agent") {
|
|
||||||
return {
|
|
||||||
...part,
|
|
||||||
source: {
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
value: text,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!part.source?.text) {
|
|
||||||
return part
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...part,
|
|
||||||
source: {
|
|
||||||
...part.source,
|
|
||||||
text: {
|
|
||||||
...part.source.text,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
value: text,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,340 +0,0 @@
|
||||||
// Pure state machine for the question UI.
|
|
||||||
//
|
|
||||||
// Supports both single-question and multi-question flows. Single questions
|
|
||||||
// submit immediately on selection. Multi-question flows use tabs and a
|
|
||||||
// final confirmation step.
|
|
||||||
//
|
|
||||||
// State transitions:
|
|
||||||
// questionSelect → picks an option (single: submits, multi: toggles/advances)
|
|
||||||
// questionSave → saves custom text input
|
|
||||||
// questionMove → arrow key navigation through options
|
|
||||||
// questionSetTab → tab navigation between questions
|
|
||||||
// questionSubmit → builds the final QuestionReply with all answers
|
|
||||||
//
|
|
||||||
// Custom answers: if a question has custom=true, an extra "Type your own
|
|
||||||
// answer" option appears. Selecting it enters editing mode with a text field.
|
|
||||||
import type { QuestionV2Info, QuestionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import type { QuestionReject, QuestionReply } from "./types"
|
|
||||||
|
|
||||||
export type QuestionBodyState = {
|
|
||||||
requestID: string
|
|
||||||
tab: number
|
|
||||||
answers: string[][]
|
|
||||||
custom: string[]
|
|
||||||
selected: number
|
|
||||||
editing: boolean
|
|
||||||
submitting: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionStep = {
|
|
||||||
state: QuestionBodyState
|
|
||||||
reply?: QuestionReply
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createQuestionBodyState(requestID: string): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
requestID,
|
|
||||||
tab: 0,
|
|
||||||
answers: [],
|
|
||||||
custom: [],
|
|
||||||
selected: 0,
|
|
||||||
editing: false,
|
|
||||||
submitting: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSync(state: QuestionBodyState, requestID: string): QuestionBodyState {
|
|
||||||
if (state.requestID === requestID) {
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
return createQuestionBodyState(requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSingle(request: QuestionV2Request): boolean {
|
|
||||||
return request.questions.length === 1 && request.questions[0]?.multiple !== true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionTabs(request: QuestionV2Request): number {
|
|
||||||
return questionSingle(request) ? 1 : request.questions.length + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionConfirm(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
|
||||||
return !questionSingle(request) && state.tab === request.questions.length
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionInfo(request: QuestionV2Request, state: QuestionBodyState): QuestionV2Info | undefined {
|
|
||||||
return request.questions[state.tab]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionCustom(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
|
||||||
return questionInfo(request, state)?.custom !== false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionInput(state: QuestionBodyState): string {
|
|
||||||
return state.custom[state.tab] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionPicked(state: QuestionBodyState): boolean {
|
|
||||||
const value = questionInput(state)
|
|
||||||
if (!value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.answers[state.tab]?.includes(value) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionOther(request: QuestionV2Request, state: QuestionBodyState): boolean {
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (!info || info.custom === false) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.selected === info.options.length
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionTotal(request: QuestionV2Request, state: QuestionBodyState): number {
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (!info) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return info.options.length + (questionCustom(request, state) ? 1 : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionAnswers(state: QuestionBodyState, count: number): string[][] {
|
|
||||||
return Array.from({ length: count }, (_, idx) => state.answers[idx] ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSetTab(state: QuestionBodyState, tab: number): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
tab,
|
|
||||||
selected: 0,
|
|
||||||
editing: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSetSelected(state: QuestionBodyState, selected: number): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
selected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSetEditing(state: QuestionBodyState, editing: boolean): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
editing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSetSubmitting(state: QuestionBodyState, submitting: boolean): QuestionBodyState {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
submitting,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function storeAnswers(state: QuestionBodyState, tab: number, list: string[]): QuestionBodyState {
|
|
||||||
const answers = [...state.answers]
|
|
||||||
answers[tab] = list
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
answers,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionStoreCustom(state: QuestionBodyState, tab: number, text: string): QuestionBodyState {
|
|
||||||
const custom = [...state.custom]
|
|
||||||
custom[tab] = text
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
custom,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function questionPick(
|
|
||||||
state: QuestionBodyState,
|
|
||||||
request: QuestionV2Request,
|
|
||||||
answer: string,
|
|
||||||
custom = false,
|
|
||||||
): QuestionStep {
|
|
||||||
const answers = [...state.answers]
|
|
||||||
answers[state.tab] = [answer]
|
|
||||||
let next: QuestionBodyState = {
|
|
||||||
...state,
|
|
||||||
answers,
|
|
||||||
editing: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (custom) {
|
|
||||||
const list = [...state.custom]
|
|
||||||
list[state.tab] = answer
|
|
||||||
next = {
|
|
||||||
...next,
|
|
||||||
custom: list,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionSingle(request)) {
|
|
||||||
return {
|
|
||||||
state: next,
|
|
||||||
reply: {
|
|
||||||
requestID: request.id,
|
|
||||||
answers: [[answer]],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: questionSetTab(next, state.tab + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function questionToggle(state: QuestionBodyState, answer: string): QuestionBodyState {
|
|
||||||
const list = [...(state.answers[state.tab] ?? [])]
|
|
||||||
const idx = list.indexOf(answer)
|
|
||||||
if (idx === -1) {
|
|
||||||
list.push(answer)
|
|
||||||
} else {
|
|
||||||
list.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return storeAnswers(state, state.tab, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionMove(state: QuestionBodyState, request: QuestionV2Request, dir: -1 | 1): QuestionBodyState {
|
|
||||||
const total = questionTotal(request, state)
|
|
||||||
if (total === 0) {
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
selected: (state.selected + dir + total) % total,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSelect(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (!info) {
|
|
||||||
return { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionOther(request, state)) {
|
|
||||||
if (!info.multiple) {
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(state, true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = questionInput(state)
|
|
||||||
if (value && questionPicked(state)) {
|
|
||||||
return {
|
|
||||||
state: questionToggle(state, value),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(state, true),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const option = info.options[state.selected]
|
|
||||||
if (!option) {
|
|
||||||
return { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.multiple) {
|
|
||||||
return {
|
|
||||||
state: questionToggle(state, option.label),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return questionPick(state, request, option.label)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSave(state: QuestionBodyState, request: QuestionV2Request): QuestionStep {
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (!info) {
|
|
||||||
return { state }
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = questionInput(state).trim()
|
|
||||||
const prev = state.custom[state.tab]
|
|
||||||
if (!value) {
|
|
||||||
if (!prev) {
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(state, false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = questionStoreCustom(state, state.tab, "")
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(
|
|
||||||
storeAnswers(
|
|
||||||
next,
|
|
||||||
state.tab,
|
|
||||||
(state.answers[state.tab] ?? []).filter((item) => item !== prev),
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.multiple) {
|
|
||||||
const answers = [...(state.answers[state.tab] ?? [])]
|
|
||||||
if (prev) {
|
|
||||||
const idx = answers.indexOf(prev)
|
|
||||||
if (idx !== -1) {
|
|
||||||
answers.splice(idx, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!answers.includes(value)) {
|
|
||||||
answers.push(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = questionStoreCustom(state, state.tab, value)
|
|
||||||
return {
|
|
||||||
state: questionSetEditing(storeAnswers(next, state.tab, answers), false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return questionPick(state, request, value, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionSubmit(request: QuestionV2Request, state: QuestionBodyState): QuestionReply {
|
|
||||||
return {
|
|
||||||
requestID: request.id,
|
|
||||||
answers: questionAnswers(state, request.questions.length),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionReject(request: QuestionV2Request): QuestionReject {
|
|
||||||
return {
|
|
||||||
requestID: request.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function questionHint(request: QuestionV2Request, state: QuestionBodyState): string {
|
|
||||||
if (state.submitting) {
|
|
||||||
return "Waiting for question event..."
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionConfirm(request, state)) {
|
|
||||||
return "enter submit esc dismiss"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.editing) {
|
|
||||||
return "enter save esc cancel"
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = questionInfo(request, state)
|
|
||||||
if (questionSingle(request)) {
|
|
||||||
return `↑↓ select enter ${info?.multiple ? "toggle" : "submit"} esc dismiss`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `⇆ tab ↑↓ select enter ${info?.multiple ? "toggle" : "confirm"} esc dismiss`
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
// Boot-time resolution for direct interactive mode.
|
|
||||||
//
|
|
||||||
// These functions run concurrently at startup to gather everything the runtime
|
|
||||||
// needs before the first frame: TUI keymap config, diff display style,
|
|
||||||
// model variant list with context limits, and session history for the prompt
|
|
||||||
// history ring. All are async because they read config or hit the SDK, but
|
|
||||||
// none block each other.
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { resolve } from "@opencode-ai/tui/config/v1"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
|
||||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
|
||||||
import { loadRunProviders } from "./catalog.shared"
|
|
||||||
import { resolveCurrentSession, sessionHistory } from "./session.shared"
|
|
||||||
import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types"
|
|
||||||
import { pickVariant } from "./variant.shared"
|
|
||||||
|
|
||||||
export type ModelInfo = {
|
|
||||||
providers: RunProvider[]
|
|
||||||
variants: string[]
|
|
||||||
limits: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionInfo = {
|
|
||||||
first: boolean
|
|
||||||
history: RunPrompt[]
|
|
||||||
model?: NonNullable<RunInput["model"]>
|
|
||||||
variant: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
type BootService = {
|
|
||||||
readonly resolveModelInfo: (
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
directory: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
) => Effect.Effect<ModelInfo>
|
|
||||||
readonly resolveSessionInfo: (
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
sessionID: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
) => Effect.Effect<SessionInfo>
|
|
||||||
}
|
|
||||||
|
|
||||||
class Service extends Context.Service<Service, BootService>()("@opencode/RunBoot") {}
|
|
||||||
|
|
||||||
function emptyModelInfo(): ModelInfo {
|
|
||||||
return {
|
|
||||||
providers: [],
|
|
||||||
variants: [],
|
|
||||||
limits: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptySessionInfo(): SessionInfo {
|
|
||||||
return {
|
|
||||||
first: true,
|
|
||||||
history: [],
|
|
||||||
variant: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultRunTuiConfig(): RunTuiConfig {
|
|
||||||
return {
|
|
||||||
...resolve({}, { terminalSuspend: process.platform !== "win32" }),
|
|
||||||
diff_style: "auto",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const resolveModelInfo = Effect.fn("RunBoot.resolveModelInfo")(function* (
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
directory: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
) {
|
|
||||||
const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory))
|
|
||||||
const limits = Object.fromEntries(
|
|
||||||
providers.flatMap((provider) =>
|
|
||||||
Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => {
|
|
||||||
const limit = info?.limit?.context
|
|
||||||
if (typeof limit !== "number" || limit <= 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return [[`${provider.id}/${modelID}`, limit] as const]
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!model) {
|
|
||||||
return {
|
|
||||||
providers,
|
|
||||||
variants: [],
|
|
||||||
limits,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]
|
|
||||||
return {
|
|
||||||
providers,
|
|
||||||
variants: Object.keys(info?.variants ?? {}),
|
|
||||||
limits,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveSessionInfo = Effect.fn("RunBoot.resolveSessionInfo")(function* (
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
sessionID: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
) {
|
|
||||||
const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined))
|
|
||||||
if (!session) {
|
|
||||||
return emptySessionInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
first: session.first,
|
|
||||||
history: sessionHistory(session),
|
|
||||||
model: session.model,
|
|
||||||
variant: pickVariant(model ?? session.model, session),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
resolveModelInfo,
|
|
||||||
resolveSessionInfo,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
|
||||||
const runtime = makeRuntime(Service, AppNodeBuilder.build(node))
|
|
||||||
|
|
||||||
// Fetches available variants and context limits for every provider/model pair.
|
|
||||||
export async function resolveModelInfo(
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
directory: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
): Promise<ModelInfo> {
|
|
||||||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model)).catch(() => emptyModelInfo())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveModelInfoStrict(sdk: RunInput["sdk"], directory: string, model: RunInput["model"]) {
|
|
||||||
return runtime.runPromise((svc) => svc.resolveModelInfo(sdk, directory, model))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches session messages to determine if this is the first turn and build prompt history.
|
|
||||||
export async function resolveSessionInfo(
|
|
||||||
sdk: RunInput["sdk"],
|
|
||||||
sessionID: string,
|
|
||||||
model: RunInput["model"],
|
|
||||||
): Promise<SessionInfo> {
|
|
||||||
return runtime.runPromise((svc) => svc.resolveSessionInfo(sdk, sessionID, model)).catch(() => emptySessionInfo())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads TUI config once for direct mode keymap setup and display preferences.
|
|
||||||
export async function resolveRunTuiConfig(
|
|
||||||
config?: RunTuiConfig | Promise<RunTuiConfig>,
|
|
||||||
): Promise<RunTuiConfig> {
|
|
||||||
return Promise.resolve(config).then((value) => value ?? defaultRunTuiConfig()).catch(() => defaultRunTuiConfig())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveDiffStyle(config?: RunTuiConfig | Promise<RunTuiConfig>): Promise<RunDiffStyle> {
|
|
||||||
return resolveRunTuiConfig(config).then((value) => value.diff_style ?? "auto")
|
|
||||||
}
|
|
||||||
|
|
@ -1,398 +0,0 @@
|
||||||
// Lifecycle management for the split-footer renderer.
|
|
||||||
//
|
|
||||||
// Creates the OpenTUI CliRenderer in split-footer mode, resolves the theme
|
|
||||||
// from the terminal palette, writes the entry splash to scrollback, and
|
|
||||||
// constructs the RunFooter. Returns a Lifecycle handle whose close() writes
|
|
||||||
// the exit splash and tears everything down in the right order:
|
|
||||||
// footer.close → footer.destroy → renderer shutdown.
|
|
||||||
//
|
|
||||||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
|
||||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
|
||||||
import path from "path"
|
|
||||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
|
|
||||||
import { isDefaultTitle } from "@opencode-ai/tui/util/session"
|
|
||||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
|
||||||
import { resolveInteractiveStdin } from "./runtime.stdin"
|
|
||||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
|
||||||
import { resolveRunTheme } from "./theme"
|
|
||||||
import type {
|
|
||||||
FooterApi,
|
|
||||||
PermissionReply,
|
|
||||||
QuestionReject,
|
|
||||||
QuestionReply,
|
|
||||||
RunAgent,
|
|
||||||
RunInput,
|
|
||||||
RunPrompt,
|
|
||||||
RunReference,
|
|
||||||
RunTuiConfig,
|
|
||||||
} from "./types"
|
|
||||||
import { formatModelLabel } from "./variant.shared"
|
|
||||||
|
|
||||||
const FOOTER_HEIGHT = 4
|
|
||||||
|
|
||||||
type SplashState = {
|
|
||||||
entry: boolean
|
|
||||||
exit: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type CycleResult = {
|
|
||||||
modelLabel?: string
|
|
||||||
status?: string
|
|
||||||
variant?: string | undefined
|
|
||||||
variants?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type FooterLabels = {
|
|
||||||
agentLabel: string
|
|
||||||
modelLabel: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LifecycleInput = {
|
|
||||||
directory: string
|
|
||||||
findFiles: (query: string) => Promise<string[]>
|
|
||||||
agents: RunAgent[]
|
|
||||||
references: RunReference[]
|
|
||||||
sessionID: string
|
|
||||||
sessionTitle?: string
|
|
||||||
getSessionID?: () => string | undefined
|
|
||||||
first: boolean
|
|
||||||
history: RunPrompt[]
|
|
||||||
agent: string | undefined
|
|
||||||
model: RunInput["model"]
|
|
||||||
variant: string | undefined
|
|
||||||
tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
|
|
||||||
onPermissionReply: (input: PermissionReply) => void | Promise<void>
|
|
||||||
onQuestionReply: (input: QuestionReply) => void | Promise<void>
|
|
||||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
|
||||||
onCycleVariant?: () => CycleResult | void
|
|
||||||
onModelSelect?: (model: NonNullable<RunInput["model"]>) => CycleResult | void | Promise<CycleResult | void>
|
|
||||||
onVariantSelect?: (variant: string | undefined) => CycleResult | void | Promise<CycleResult | void>
|
|
||||||
onInterrupt?: () => void
|
|
||||||
onBackground?: () => void
|
|
||||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
|
||||||
onSubagentInterrupt?: (sessionID: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Lifecycle = {
|
|
||||||
footer: FooterApi
|
|
||||||
onResize(fn: () => void): () => void
|
|
||||||
refreshTheme(): void
|
|
||||||
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
|
||||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gracefully tears down the renderer. Order matters: switch external output
|
|
||||||
// back to passthrough before leaving split-footer mode, so pending stdout
|
|
||||||
// doesn't get captured into the now-dead scrollback pipeline.
|
|
||||||
function shutdown(renderer: CliRenderer): void {
|
|
||||||
if (renderer.isDestroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderer.externalOutputMode === "capture-stdout") {
|
|
||||||
renderer.externalOutputMode = "passthrough"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderer.screenMode === "split-footer") {
|
|
||||||
renderer.screenMode = "main-screen"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!renderer.isDestroyed) {
|
|
||||||
renderer.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function splashInfo(title: string | undefined, history: RunPrompt[]) {
|
|
||||||
if (title && !isDefaultTitle(title)) {
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
showSession: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = history.find((item) => item.text.trim().length > 0)
|
|
||||||
return {
|
|
||||||
title: next?.text ?? title,
|
|
||||||
showSession: !!next,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
|
|
||||||
const agentLabel = Locale.titlecase(input.agent ?? "build")
|
|
||||||
return {
|
|
||||||
agentLabel,
|
|
||||||
modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function directoryLabel(directory: string) {
|
|
||||||
const resolved = path.resolve(directory)
|
|
||||||
const display =
|
|
||||||
resolved === Global.Path.home
|
|
||||||
? "~"
|
|
||||||
: resolved.startsWith(`${Global.Path.home}${path.sep}`)
|
|
||||||
? resolved.replace(Global.Path.home, "~")
|
|
||||||
: resolved
|
|
||||||
return display.replaceAll("\\", "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueSplash(
|
|
||||||
renderer: Pick<CliRenderer, "writeToScrollback" | "requestRender">,
|
|
||||||
state: SplashState,
|
|
||||||
phase: keyof SplashState,
|
|
||||||
write: ScrollbackWriter | undefined,
|
|
||||||
): boolean {
|
|
||||||
if (state[phase]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!write) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
state[phase] = true
|
|
||||||
renderer.writeToScrollback(write)
|
|
||||||
renderer.requestRender()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boots the split-footer renderer and constructs the RunFooter.
|
|
||||||
//
|
|
||||||
// The renderer starts in split-footer mode with captured stdout so that
|
|
||||||
// scrollback commits and footer repaints happen in the same frame. After
|
|
||||||
// the entry splash, RunFooter takes over the footer region.
|
|
||||||
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
|
|
||||||
const source = resolveInteractiveStdin()
|
|
||||||
const footerTask = import("./footer")
|
|
||||||
let unregisterKeymap: (() => void) | undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
const renderer = await createCliRenderer({
|
|
||||||
stdin: source.stdin,
|
|
||||||
targetFps: 30,
|
|
||||||
maxFps: 60,
|
|
||||||
useMouse: false,
|
|
||||||
autoFocus: false,
|
|
||||||
openConsoleOnError: false,
|
|
||||||
exitOnCtrlC: false,
|
|
||||||
useKittyKeyboard: { events: process.platform === "win32" },
|
|
||||||
screenMode: "split-footer",
|
|
||||||
footerHeight: FOOTER_HEIGHT,
|
|
||||||
externalOutputMode: "capture-stdout",
|
|
||||||
consoleMode: "disabled",
|
|
||||||
clearOnShutdown: false,
|
|
||||||
})
|
|
||||||
const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
|
|
||||||
renderer.setBackgroundColor(theme.background)
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
|
|
||||||
const state: SplashState = {
|
|
||||||
entry: false,
|
|
||||||
exit: false,
|
|
||||||
}
|
|
||||||
const splash = splashInfo(input.sessionTitle, input.history)
|
|
||||||
const meta = splashMeta({
|
|
||||||
title: splash.title,
|
|
||||||
session_id: input.sessionID,
|
|
||||||
})
|
|
||||||
const labels = footerLabels({
|
|
||||||
agent: input.agent,
|
|
||||||
model: input.model,
|
|
||||||
variant: input.variant,
|
|
||||||
})
|
|
||||||
const wrote = queueSplash(
|
|
||||||
renderer,
|
|
||||||
state,
|
|
||||||
"entry",
|
|
||||||
entrySplash({
|
|
||||||
...meta,
|
|
||||||
theme: theme.splash,
|
|
||||||
showSession: splash.showSession,
|
|
||||||
detail: directoryLabel(input.directory),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await renderer.idle().catch(() => {})
|
|
||||||
|
|
||||||
const { RunFooter } = await footerTask
|
|
||||||
let closed = false
|
|
||||||
let sigintRegistered = false
|
|
||||||
|
|
||||||
const footer = new RunFooter(renderer, {
|
|
||||||
directory: input.directory,
|
|
||||||
findFiles: input.findFiles,
|
|
||||||
agents: input.agents,
|
|
||||||
references: input.references,
|
|
||||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
|
||||||
...labels,
|
|
||||||
model: input.model,
|
|
||||||
variant: input.variant,
|
|
||||||
first: input.first,
|
|
||||||
history: input.history,
|
|
||||||
theme,
|
|
||||||
wrote,
|
|
||||||
keymap,
|
|
||||||
tuiConfig,
|
|
||||||
diffStyle: tuiConfig.diff_style ?? "auto",
|
|
||||||
onPermissionReply: input.onPermissionReply,
|
|
||||||
onQuestionReply: input.onQuestionReply,
|
|
||||||
onQuestionReject: input.onQuestionReject,
|
|
||||||
onCycleVariant: input.onCycleVariant,
|
|
||||||
onModelSelect: input.onModelSelect,
|
|
||||||
onVariantSelect: input.onVariantSelect,
|
|
||||||
onInterrupt: input.onInterrupt,
|
|
||||||
onBackground: input.onBackground,
|
|
||||||
onEditorOpen: async ({ value }) => {
|
|
||||||
if (closed || renderer.isDestroyed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { openEditor } = await import("@opencode-ai/tui/editor")
|
|
||||||
await renderer.idle().catch(() => {})
|
|
||||||
const ignore = () => {}
|
|
||||||
detachSigint()
|
|
||||||
process.on("SIGINT", ignore)
|
|
||||||
try {
|
|
||||||
return await openEditor({
|
|
||||||
value,
|
|
||||||
cwd: input.directory,
|
|
||||||
renderer,
|
|
||||||
stdin: source.stdin,
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
process.off("SIGINT", ignore)
|
|
||||||
attachSigint()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSubagentSelect: input.onSubagentSelect,
|
|
||||||
onSubagentInterrupt: input.onSubagentInterrupt,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sigint = () => {
|
|
||||||
footer.requestExit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachSigint = () => {
|
|
||||||
if (closed || sigintRegistered) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGINT", sigint)
|
|
||||||
sigintRegistered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const detachSigint = () => {
|
|
||||||
if (!sigintRegistered) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
process.off("SIGINT", sigint)
|
|
||||||
sigintRegistered = false
|
|
||||||
}
|
|
||||||
|
|
||||||
attachSigint()
|
|
||||||
|
|
||||||
const close = async (next: {
|
|
||||||
showExit: boolean
|
|
||||||
sessionTitle?: string
|
|
||||||
sessionID?: string
|
|
||||||
history?: RunPrompt[]
|
|
||||||
}) => {
|
|
||||||
if (closed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closed = true
|
|
||||||
detachSigint()
|
|
||||||
let wroteExit = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
await footer.idle().catch(() => {})
|
|
||||||
|
|
||||||
const show = renderer.isDestroyed ? false : next.showExit
|
|
||||||
if (!renderer.isDestroyed && show) {
|
|
||||||
const sessionID = next.sessionID || input.getSessionID?.() || input.sessionID
|
|
||||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history ?? input.history)
|
|
||||||
wroteExit = queueSplash(
|
|
||||||
renderer,
|
|
||||||
state,
|
|
||||||
"exit",
|
|
||||||
exitSplash({
|
|
||||||
...splashMeta({
|
|
||||||
title: splash.title,
|
|
||||||
session_id: sessionID,
|
|
||||||
}),
|
|
||||||
theme: footer.currentTheme().splash,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
await renderer.idle().catch(() => {})
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
footer.close()
|
|
||||||
await footer.idle().catch(() => {})
|
|
||||||
footer.destroy()
|
|
||||||
unregisterKeymap?.()
|
|
||||||
shutdown(renderer)
|
|
||||||
if (!wroteExit) {
|
|
||||||
process.stdout.write("\n")
|
|
||||||
}
|
|
||||||
source.cleanup?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
footer,
|
|
||||||
refreshTheme() {
|
|
||||||
footer.refreshTheme()
|
|
||||||
},
|
|
||||||
onResize(fn) {
|
|
||||||
let width = renderer.terminalWidth
|
|
||||||
let height = renderer.terminalHeight
|
|
||||||
const resize = () => {
|
|
||||||
if (width === renderer.terminalWidth && height === renderer.terminalHeight) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
width = renderer.terminalWidth
|
|
||||||
height = renderer.terminalHeight
|
|
||||||
fn()
|
|
||||||
}
|
|
||||||
renderer.on(CliRenderEvents.RESIZE, resize)
|
|
||||||
return () => renderer.off(CliRenderEvents.RESIZE, resize)
|
|
||||||
},
|
|
||||||
async resetForReplay(next) {
|
|
||||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
|
||||||
throw new Error("runtime closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
await footer.idle()
|
|
||||||
if (closed || renderer.isDestroyed || footer.isClosed) {
|
|
||||||
throw new Error("runtime closed")
|
|
||||||
}
|
|
||||||
|
|
||||||
footer.resetForReplay(true)
|
|
||||||
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
|
|
||||||
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
|
|
||||||
renderer.writeToScrollback(
|
|
||||||
entrySplash({
|
|
||||||
...splashMeta({
|
|
||||||
title: splash.title,
|
|
||||||
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
|
||||||
}),
|
|
||||||
theme: footer.currentTheme().splash,
|
|
||||||
showSession: splash.showSession,
|
|
||||||
detail: directoryLabel(input.directory),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
renderer.requestRender()
|
|
||||||
},
|
|
||||||
close,
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
unregisterKeymap?.()
|
|
||||||
source.cleanup?.()
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
type PendingTask<T> = {
|
|
||||||
current?: Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reusePendingTask<T>(slot: PendingTask<T>, run: () => Promise<T>) {
|
|
||||||
if (slot.current) {
|
|
||||||
return slot.current
|
|
||||||
}
|
|
||||||
|
|
||||||
const task = run().finally(() => {
|
|
||||||
if (slot.current === task) {
|
|
||||||
slot.current = undefined
|
|
||||||
}
|
|
||||||
})
|
|
||||||
slot.current = task
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import fs from "fs"
|
|
||||||
import * as tty from "node:tty"
|
|
||||||
|
|
||||||
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
|
|
||||||
|
|
||||||
type InteractiveStdin = {
|
|
||||||
stdin: NodeJS.ReadStream
|
|
||||||
cleanup?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function openTerminalStdin(path: string): NodeJS.ReadStream {
|
|
||||||
return new tty.ReadStream(fs.openSync(path, "r"))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveInteractiveStdin(
|
|
||||||
stdin: NodeJS.ReadStream = process.stdin,
|
|
||||||
open: (path: string) => NodeJS.ReadStream = openTerminalStdin,
|
|
||||||
platform = process.platform,
|
|
||||||
): InteractiveStdin {
|
|
||||||
if (stdin.isTTY) {
|
|
||||||
return { stdin }
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = platform === "win32" ? "CONIN$" : "/dev/tty"
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stream = open(file)
|
|
||||||
return {
|
|
||||||
stdin: stream,
|
|
||||||
cleanup: () => {
|
|
||||||
stream.destroy()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
|
|
||||||
import type { FooterView } from "./types"
|
|
||||||
|
|
||||||
export function pickBlockerView(input: {
|
|
||||||
permission?: PermissionV2Request
|
|
||||||
question?: QuestionV2Request
|
|
||||||
}): FooterView {
|
|
||||||
if (input.permission) return { type: "permission", request: input.permission }
|
|
||||||
if (input.question) return { type: "question", request: input.question }
|
|
||||||
return { type: "prompt" }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blockerStatus(view: FooterView) {
|
|
||||||
if (view.type === "permission") return "awaiting permission"
|
|
||||||
if (view.type === "question") return "awaiting answer"
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
@ -1,786 +0,0 @@
|
||||||
// Current-native subagent (child Session) tracking for the mini transport.
|
|
||||||
//
|
|
||||||
// Discovers child Sessions of the active parent from four current sources:
|
|
||||||
// 1. projected subagent tool output (`structured.sessionID`) during hydration
|
|
||||||
// 2. the current session list filtered by `parentID` during hydration
|
|
||||||
// 3. the process-local active-session map during hydration
|
|
||||||
// 4. live events from unknown sessions whose `parentID` matches the parent
|
|
||||||
//
|
|
||||||
// Tracks one footer tab per child and a detail transcript for the selected
|
|
||||||
// child, reduced from the same current live event stream the parent uses.
|
|
||||||
// Detail transcripts rebuild from projected messages on discovery, selection,
|
|
||||||
// and reconnect, then continue from live deltas using the same
|
|
||||||
// projected-prefix dedup the parent transport uses.
|
|
||||||
//
|
|
||||||
// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child
|
|
||||||
// backgrounding is intentionally absent: subagent jobs block the parent
|
|
||||||
// session, so only whole-session `v2.session.background(parentID)` exists.
|
|
||||||
import type {
|
|
||||||
EventSubscribeOutput,
|
|
||||||
OpenCodeClient,
|
|
||||||
SessionMessageAssistantTool,
|
|
||||||
SessionMessageInfo,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
|
||||||
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, MiniToolPart, StreamCommit } from "./types"
|
|
||||||
|
|
||||||
const CHILD_MESSAGE_LIMIT = 80
|
|
||||||
const CHILD_FRAME_LIMIT = 80
|
|
||||||
const CHILD_EVENT_BUFFER_LIMIT = 64
|
|
||||||
const FAMILY_LIST_LIMIT = 100
|
|
||||||
const FALLBACK_LABEL = "Subagent"
|
|
||||||
|
|
||||||
type V2Event = EventSubscribeOutput
|
|
||||||
|
|
||||||
export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) {
|
|
||||||
return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function miniTool(input: {
|
|
||||||
sessionID: string
|
|
||||||
messageID: string
|
|
||||||
tool: SessionMessageAssistantTool
|
|
||||||
}): MiniToolPart {
|
|
||||||
const tool = input.tool
|
|
||||||
const providerCall =
|
|
||||||
tool.executed === undefined && tool.providerState === undefined
|
|
||||||
? undefined
|
|
||||||
: { executed: tool.executed, state: tool.providerState }
|
|
||||||
const providerResult =
|
|
||||||
tool.executed === undefined && tool.providerResultState === undefined
|
|
||||||
? undefined
|
|
||||||
: { executed: tool.executed, state: tool.providerResultState }
|
|
||||||
const base = {
|
|
||||||
id: `prt_${tool.id}`,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
messageID: input.messageID,
|
|
||||||
type: "tool" as const,
|
|
||||||
callID: tool.id,
|
|
||||||
tool: tool.name,
|
|
||||||
}
|
|
||||||
if (tool.state.status === "streaming") {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
state: { status: "pending", input: {}, raw: tool.state.input },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tool.state.status === "running") {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
state: {
|
|
||||||
status: "running",
|
|
||||||
input: tool.state.input,
|
|
||||||
title: tool.name,
|
|
||||||
metadata: { structured: tool.state.structured, content: tool.state.content, providerCall },
|
|
||||||
time: { start: tool.time.ran ?? tool.time.created },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tool.state.status === "completed") {
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: tool.state.input,
|
|
||||||
output: outputText(tool.state.content),
|
|
||||||
title: tool.name,
|
|
||||||
metadata: {
|
|
||||||
structured: tool.state.structured,
|
|
||||||
content: tool.state.content,
|
|
||||||
result: tool.state.result,
|
|
||||||
providerCall,
|
|
||||||
providerResult,
|
|
||||||
},
|
|
||||||
time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
state: {
|
|
||||||
status: "error",
|
|
||||||
input: tool.state.input,
|
|
||||||
error: tool.state.error.message,
|
|
||||||
metadata: {
|
|
||||||
structured: tool.state.structured,
|
|
||||||
content: tool.state.content,
|
|
||||||
result: tool.state.result,
|
|
||||||
providerCall,
|
|
||||||
providerResult,
|
|
||||||
},
|
|
||||||
time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toolCommit(part: MiniToolPart, phase: "start" | "progress" | "final"): StreamCommit {
|
|
||||||
const status = part.state.status
|
|
||||||
const text =
|
|
||||||
status === "running"
|
|
||||||
? part.tool === "task"
|
|
||||||
? "running task"
|
|
||||||
: `running ${part.tool}`
|
|
||||||
: status === "completed"
|
|
||||||
? part.state.output
|
|
||||||
: status === "error"
|
|
||||||
? part.state.error
|
|
||||||
: ""
|
|
||||||
return {
|
|
||||||
kind: "tool",
|
|
||||||
source: "tool",
|
|
||||||
text,
|
|
||||||
phase,
|
|
||||||
messageID: part.messageID,
|
|
||||||
partID: part.id,
|
|
||||||
tool: part.tool,
|
|
||||||
part,
|
|
||||||
toolState: status === "error" ? "error" : status === "completed" ? "completed" : "running",
|
|
||||||
toolError: status === "error" ? part.state.error : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Frame = {
|
|
||||||
key: string
|
|
||||||
commit: StreamCommit
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolTrack = {
|
|
||||||
name: string
|
|
||||||
input: Record<string, unknown>
|
|
||||||
started: number
|
|
||||||
providerState?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChildState = {
|
|
||||||
sessionID: string
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
status: FooterSubagentTab["status"]
|
|
||||||
background: boolean
|
|
||||||
title?: string
|
|
||||||
callIDs: Set<string>
|
|
||||||
lastUpdatedAt: number
|
|
||||||
frames: Frame[]
|
|
||||||
text: Map<string, string>
|
|
||||||
projectedText: Map<string, string>
|
|
||||||
reasoning: Map<string, string>
|
|
||||||
projectedReasoning: Map<string, string>
|
|
||||||
tools: Map<string, ToolTrack>
|
|
||||||
finishedTools: Set<string>
|
|
||||||
messageIDs: Set<string>
|
|
||||||
prompts: Map<string, string>
|
|
||||||
hydrated: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SubagentTrackerInput = {
|
|
||||||
sdk: OpenCodeClient
|
|
||||||
sessionID: string
|
|
||||||
thinking: boolean
|
|
||||||
emit: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SubagentTracker = {
|
|
||||||
main(event: V2Event): void
|
|
||||||
foreign(sessionID: string, event: V2Event): void
|
|
||||||
hydrate(next: { messages: SessionMessageInfo[]; active: Record<string, unknown> }): Promise<void>
|
|
||||||
select(sessionID: string | undefined): void
|
|
||||||
snapshot(): FooterSubagentState
|
|
||||||
}
|
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> | undefined {
|
|
||||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(value: unknown): string | undefined {
|
|
||||||
if (typeof value !== "string") return undefined
|
|
||||||
const next = value.trim()
|
|
||||||
return next || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function childSessionID(structured: Record<string, unknown> | undefined) {
|
|
||||||
const sessionID = text(structured?.sessionID)
|
|
||||||
if (!sessionID || !sessionID.startsWith("ses")) return undefined
|
|
||||||
const status = structured?.status
|
|
||||||
if (status !== "running" && status !== "completed") return undefined
|
|
||||||
return { sessionID, running: status === "running" }
|
|
||||||
}
|
|
||||||
|
|
||||||
function tab(child: ChildState): FooterSubagentTab {
|
|
||||||
return {
|
|
||||||
sessionID: child.sessionID,
|
|
||||||
partID: `subagent:${child.sessionID}`,
|
|
||||||
callID: `subagent:${child.sessionID}`,
|
|
||||||
label: child.label,
|
|
||||||
description: child.description || child.title || "",
|
|
||||||
status: child.status,
|
|
||||||
background: child.background ? true : undefined,
|
|
||||||
title: child.title,
|
|
||||||
toolCalls: child.callIDs.size > 0 ? child.callIDs.size : undefined,
|
|
||||||
lastUpdatedAt: child.lastUpdatedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker {
|
|
||||||
const children = new Map<string, ChildState>()
|
|
||||||
// Live subagent tool calls in the parent, so tool.success structured output
|
|
||||||
// can be joined with the call's input metadata.
|
|
||||||
const pendingCalls = new Map<string, Record<string, unknown>>()
|
|
||||||
// Foreign sessions already resolved through session.get. Non-children stay
|
|
||||||
// cached so unrelated concurrent sessions are checked at most once.
|
|
||||||
const checked = new Set<string>()
|
|
||||||
// Foreign events buffered while a session.get discovery is in flight, so a
|
|
||||||
// fast child (including its settled event) is not lost mid-discovery.
|
|
||||||
const pendingEvents = new Map<string, V2Event[]>()
|
|
||||||
const hydrationEvents = new Map<string, V2Event[]>()
|
|
||||||
const hydrationOverflow = new Set<string>()
|
|
||||||
const hydrations = new Map<string, Promise<void>>()
|
|
||||||
let selected: string | undefined
|
|
||||||
const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}`
|
|
||||||
|
|
||||||
const ensureChild = (sessionID: string): ChildState => {
|
|
||||||
const existing = children.get(sessionID)
|
|
||||||
const child: ChildState = existing ?? {
|
|
||||||
sessionID,
|
|
||||||
label: FALLBACK_LABEL,
|
|
||||||
description: "",
|
|
||||||
status: "running",
|
|
||||||
background: false,
|
|
||||||
callIDs: new Set(),
|
|
||||||
lastUpdatedAt: Date.now(),
|
|
||||||
frames: [],
|
|
||||||
text: new Map(),
|
|
||||||
projectedText: new Map(),
|
|
||||||
reasoning: new Map(),
|
|
||||||
projectedReasoning: new Map(),
|
|
||||||
tools: new Map(),
|
|
||||||
finishedTools: new Set(),
|
|
||||||
messageIDs: new Set(),
|
|
||||||
prompts: new Map(),
|
|
||||||
hydrated: false,
|
|
||||||
}
|
|
||||||
if (!existing) children.set(sessionID, child)
|
|
||||||
// Adopting a child while its session.get discovery is still in flight:
|
|
||||||
// drain the buffered events now. They arrived before whatever the caller
|
|
||||||
// applies next, so replaying them first preserves bus order, and the
|
|
||||||
// resolved discovery can no longer replay stale events (e.g. step.started)
|
|
||||||
// after a terminal settled event was applied directly.
|
|
||||||
const buffered = pendingEvents.get(sessionID)
|
|
||||||
if (buffered) {
|
|
||||||
pendingEvents.delete(sessionID)
|
|
||||||
for (const event of buffered) reduce(child, event)
|
|
||||||
}
|
|
||||||
return child
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = (child: ChildState, timestamp?: number) => {
|
|
||||||
child.lastUpdatedAt = Math.max(child.lastUpdatedAt, timestamp ?? Date.now())
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyDetail = (child: ChildState) => {
|
|
||||||
if (child.sessionID === selected) input.emit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const setFrame = (child: ChildState, key: string, commit: StreamCommit) => {
|
|
||||||
const index = child.frames.findIndex((item) => item.key === key)
|
|
||||||
if (index === -1) {
|
|
||||||
child.frames.push({ key, commit })
|
|
||||||
if (child.frames.length > CHILD_FRAME_LIMIT) child.frames.splice(0, child.frames.length - CHILD_FRAME_LIMIT)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
child.frames[index] = { key, commit }
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyMeta = (child: ChildState, meta: Record<string, unknown> | undefined) => {
|
|
||||||
if (!meta) return
|
|
||||||
const agent = text(meta.agent)
|
|
||||||
if (agent) child.label = Locale.titlecase(agent)
|
|
||||||
const description = text(meta.description)
|
|
||||||
if (description) child.description = description
|
|
||||||
if (meta.background === true) child.background = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const userFrame = (child: ChildState, messageID: string, value: string) => {
|
|
||||||
if (child.messageIDs.has(messageID)) return false
|
|
||||||
child.messageIDs.add(messageID)
|
|
||||||
setFrame(child, `user:${messageID}`, {
|
|
||||||
kind: "user",
|
|
||||||
source: "system",
|
|
||||||
text: value,
|
|
||||||
phase: "start",
|
|
||||||
messageID,
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => {
|
|
||||||
const part = miniTool({
|
|
||||||
sessionID: child.sessionID,
|
|
||||||
messageID,
|
|
||||||
tool: item,
|
|
||||||
})
|
|
||||||
if (item.state.status === "streaming") return
|
|
||||||
child.callIDs.add(item.id)
|
|
||||||
if (item.state.status === "running") {
|
|
||||||
setFrame(child, `tool:${item.id}`, toolCommit(part, "start"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
child.finishedTools.add(item.id)
|
|
||||||
child.tools.delete(item.id)
|
|
||||||
setFrame(child, `tool:${item.id}`, toolCommit(part, "final"))
|
|
||||||
}
|
|
||||||
|
|
||||||
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
|
|
||||||
child.frames = []
|
|
||||||
child.text.clear()
|
|
||||||
child.projectedText.clear()
|
|
||||||
child.reasoning.clear()
|
|
||||||
child.projectedReasoning.clear()
|
|
||||||
child.finishedTools.clear()
|
|
||||||
child.messageIDs.clear()
|
|
||||||
child.callIDs.clear()
|
|
||||||
for (const message of messages) {
|
|
||||||
if (message.type === "user") {
|
|
||||||
child.prompts.delete(message.id)
|
|
||||||
userFrame(child, message.id, message.text)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (message.type !== "assistant") continue
|
|
||||||
child.messageIDs.add(message.id)
|
|
||||||
let textOrdinal = 0
|
|
||||||
let reasoningOrdinal = 0
|
|
||||||
for (const item of message.content) {
|
|
||||||
if (item.type === "text") {
|
|
||||||
const id = `text:${textOrdinal++}`
|
|
||||||
const key = fragmentKey(message.id, id)
|
|
||||||
child.text.set(key, item.text)
|
|
||||||
child.projectedText.set(key, item.text)
|
|
||||||
setFrame(child, key, {
|
|
||||||
kind: "assistant",
|
|
||||||
source: "assistant",
|
|
||||||
text: item.text,
|
|
||||||
phase: "progress",
|
|
||||||
messageID: message.id,
|
|
||||||
partID: id,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (item.type === "reasoning") {
|
|
||||||
const id = `reasoning:${reasoningOrdinal++}`
|
|
||||||
const key = fragmentKey(message.id, id)
|
|
||||||
child.reasoning.set(key, item.text)
|
|
||||||
child.projectedReasoning.set(key, item.text)
|
|
||||||
if (input.thinking)
|
|
||||||
setFrame(child, key, {
|
|
||||||
kind: "reasoning",
|
|
||||||
source: "reasoning",
|
|
||||||
text: `Thinking: ${item.text}`,
|
|
||||||
phase: "progress",
|
|
||||||
messageID: message.id,
|
|
||||||
partID: id,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
childTool(child, item, message.id)
|
|
||||||
}
|
|
||||||
if (message.error) {
|
|
||||||
setFrame(child, `error:${message.id}`, {
|
|
||||||
kind: "error",
|
|
||||||
source: "system",
|
|
||||||
text: message.error.message,
|
|
||||||
phase: "start",
|
|
||||||
messageID: message.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateChild = (child: ChildState): Promise<void> => {
|
|
||||||
const existing = hydrations.get(child.sessionID)
|
|
||||||
if (existing) return existing
|
|
||||||
const pendingPrompts = new Map(child.prompts)
|
|
||||||
const pendingTools = new Map(child.tools)
|
|
||||||
let retry = false
|
|
||||||
const task = input.sdk.message
|
|
||||||
.list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" })
|
|
||||||
.then((response) => {
|
|
||||||
const buffered = hydrationEvents.get(child.sessionID) ?? []
|
|
||||||
hydrationEvents.delete(child.sessionID)
|
|
||||||
if (hydrationOverflow.delete(child.sessionID)) {
|
|
||||||
child.hydrated = false
|
|
||||||
retry = true
|
|
||||||
notifyDetail(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for (const [id, prompt] of pendingPrompts) {
|
|
||||||
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
|
|
||||||
}
|
|
||||||
rebuild(child, structuredClone(response.data).toReversed() as SessionMessageInfo[])
|
|
||||||
for (const [id, tool] of pendingTools) {
|
|
||||||
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
|
|
||||||
}
|
|
||||||
for (const event of buffered) reduce(child, event)
|
|
||||||
child.hydrated = true
|
|
||||||
notifyDetail(child)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
hydrationEvents.delete(child.sessionID)
|
|
||||||
hydrationOverflow.delete(child.sessionID)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
hydrations.delete(child.sessionID)
|
|
||||||
if (retry) queueMicrotask(() => void hydrateChild(child))
|
|
||||||
})
|
|
||||||
hydrations.set(child.sessionID, task)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
const discover = (sessionID: string) => {
|
|
||||||
if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return
|
|
||||||
checked.add(sessionID)
|
|
||||||
if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, [])
|
|
||||||
void input.sdk.session
|
|
||||||
.get({ sessionID })
|
|
||||||
.then((session) => {
|
|
||||||
const buffered = pendingEvents.get(sessionID) ?? []
|
|
||||||
pendingEvents.delete(sessionID)
|
|
||||||
if (session.parentID !== input.sessionID) return
|
|
||||||
const child = ensureChild(sessionID)
|
|
||||||
if (session.agent) child.label = Locale.titlecase(session.agent)
|
|
||||||
child.title = session.title
|
|
||||||
for (const event of buffered) reduce(child, event)
|
|
||||||
touch(child)
|
|
||||||
input.emit()
|
|
||||||
void hydrateChild(child)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Allow a later event to retry discovery after transient failures.
|
|
||||||
pendingEvents.delete(sessionID)
|
|
||||||
checked.delete(sessionID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const reduce = (child: ChildState, event: V2Event) => {
|
|
||||||
if (event.type === "session.input.admitted") {
|
|
||||||
if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.input.promoted") {
|
|
||||||
const prompt = child.prompts.get(event.data.inputID)
|
|
||||||
if (prompt === undefined) return
|
|
||||||
child.prompts.delete(event.data.inputID)
|
|
||||||
if (userFrame(child, event.data.inputID, prompt)) {
|
|
||||||
touch(child, event.created)
|
|
||||||
notifyDetail(child)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.step.started") {
|
|
||||||
touch(child, event.created)
|
|
||||||
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
|
|
||||||
if (child.status !== "running") child.status = "running"
|
|
||||||
input.emit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.text.started") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.text.delta") {
|
|
||||||
const id = `text:${event.data.ordinal}`
|
|
||||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
|
||||||
const projected = child.projectedText.get(key)
|
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
|
||||||
if (projected && covered >= 0) {
|
|
||||||
child.projectedText.set(key, projected.slice(covered + event.data.delta.length))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const next = (child.text.get(key) ?? "") + event.data.delta
|
|
||||||
child.text.set(key, next)
|
|
||||||
setFrame(child, key, {
|
|
||||||
kind: "assistant",
|
|
||||||
source: "assistant",
|
|
||||||
text: next,
|
|
||||||
phase: "progress",
|
|
||||||
messageID: event.data.assistantMessageID,
|
|
||||||
partID: id,
|
|
||||||
})
|
|
||||||
touch(child, event.created)
|
|
||||||
notifyDetail(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.text.ended") {
|
|
||||||
const id = `text:${event.data.ordinal}`
|
|
||||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
|
||||||
child.text.set(key, event.data.text)
|
|
||||||
child.projectedText.delete(key)
|
|
||||||
setFrame(child, key, {
|
|
||||||
kind: "assistant",
|
|
||||||
source: "assistant",
|
|
||||||
text: event.data.text,
|
|
||||||
phase: "progress",
|
|
||||||
messageID: event.data.assistantMessageID,
|
|
||||||
partID: id,
|
|
||||||
})
|
|
||||||
touch(child, event.created)
|
|
||||||
notifyDetail(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.reasoning.started") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.reasoning.delta") {
|
|
||||||
const id = `reasoning:${event.data.ordinal}`
|
|
||||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
|
||||||
const projected = child.projectedReasoning.get(key)
|
|
||||||
const covered = projected?.indexOf(event.data.delta) ?? -1
|
|
||||||
if (projected && covered >= 0) {
|
|
||||||
child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const next = (child.reasoning.get(key) ?? "") + event.data.delta
|
|
||||||
child.reasoning.set(key, next)
|
|
||||||
if (!input.thinking) return
|
|
||||||
setFrame(child, key, {
|
|
||||||
kind: "reasoning",
|
|
||||||
source: "reasoning",
|
|
||||||
text: `Thinking: ${next}`,
|
|
||||||
phase: "progress",
|
|
||||||
messageID: event.data.assistantMessageID,
|
|
||||||
partID: id,
|
|
||||||
})
|
|
||||||
notifyDetail(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.reasoning.ended") {
|
|
||||||
const id = `reasoning:${event.data.ordinal}`
|
|
||||||
const key = fragmentKey(event.data.assistantMessageID, id)
|
|
||||||
child.reasoning.set(key, event.data.text)
|
|
||||||
child.projectedReasoning.delete(key)
|
|
||||||
if (!input.thinking) return
|
|
||||||
setFrame(child, key, {
|
|
||||||
kind: "reasoning",
|
|
||||||
source: "reasoning",
|
|
||||||
text: `Thinking: ${event.data.text}`,
|
|
||||||
phase: "progress",
|
|
||||||
messageID: event.data.assistantMessageID,
|
|
||||||
partID: id,
|
|
||||||
})
|
|
||||||
notifyDetail(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.tool.input.started") {
|
|
||||||
if (child.finishedTools.has(event.data.callID)) return
|
|
||||||
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.tool.called") {
|
|
||||||
if (child.finishedTools.has(event.data.callID)) return
|
|
||||||
const current = child.tools.get(event.data.callID)
|
|
||||||
child.tools.set(event.data.callID, {
|
|
||||||
name: current?.name ?? "tool",
|
|
||||||
input: event.data.input,
|
|
||||||
started: current?.started ?? event.created,
|
|
||||||
providerState: event.data.state,
|
|
||||||
})
|
|
||||||
childTool(
|
|
||||||
child,
|
|
||||||
structuredClone({
|
|
||||||
type: "tool",
|
|
||||||
id: event.data.callID,
|
|
||||||
name: current?.name ?? "tool",
|
|
||||||
executed: event.data.executed,
|
|
||||||
providerState: event.data.state,
|
|
||||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
|
||||||
time: { created: current?.started ?? event.created, ran: event.created },
|
|
||||||
}) as SessionMessageAssistantTool,
|
|
||||||
event.data.assistantMessageID,
|
|
||||||
)
|
|
||||||
touch(child, event.created)
|
|
||||||
notifyDetail(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
|
|
||||||
if (child.finishedTools.has(event.data.callID)) return
|
|
||||||
const current = child.tools.get(event.data.callID)
|
|
||||||
const failed = event.type === "session.tool.failed"
|
|
||||||
childTool(
|
|
||||||
child,
|
|
||||||
structuredClone({
|
|
||||||
type: "tool",
|
|
||||||
id: event.data.callID,
|
|
||||||
name: current?.name ?? "tool",
|
|
||||||
executed: event.data.executed,
|
|
||||||
providerState: current?.providerState,
|
|
||||||
providerResultState: event.data.resultState,
|
|
||||||
state: failed
|
|
||||||
? {
|
|
||||||
status: "error",
|
|
||||||
input: current?.input ?? {},
|
|
||||||
structured: {},
|
|
||||||
content: [],
|
|
||||||
error: event.data.error,
|
|
||||||
result: event.data.result,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
status: "completed",
|
|
||||||
input: current?.input ?? {},
|
|
||||||
structured: event.data.structured,
|
|
||||||
content: event.data.content,
|
|
||||||
result: event.data.result,
|
|
||||||
},
|
|
||||||
time: {
|
|
||||||
created: current?.started ?? event.created,
|
|
||||||
ran: current?.started,
|
|
||||||
completed: event.created,
|
|
||||||
},
|
|
||||||
}) as SessionMessageAssistantTool,
|
|
||||||
event.data.assistantMessageID,
|
|
||||||
)
|
|
||||||
touch(child, event.created)
|
|
||||||
notifyDetail(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.step.ended") return
|
|
||||||
if (event.type === "session.step.failed") {
|
|
||||||
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
|
|
||||||
kind: "error",
|
|
||||||
source: "system",
|
|
||||||
text: event.data.error.message,
|
|
||||||
phase: "start",
|
|
||||||
messageID: event.data.assistantMessageID,
|
|
||||||
})
|
|
||||||
touch(child, event.created)
|
|
||||||
notifyDetail(child)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.execution.started") {
|
|
||||||
child.status = "running"
|
|
||||||
touch(child, event.created)
|
|
||||||
input.emit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
event.type === "session.execution.succeeded" ||
|
|
||||||
event.type === "session.execution.failed" ||
|
|
||||||
event.type === "session.execution.interrupted"
|
|
||||||
) {
|
|
||||||
child.status =
|
|
||||||
event.type === "session.execution.succeeded"
|
|
||||||
? "completed"
|
|
||||||
: event.type === "session.execution.interrupted"
|
|
||||||
? "cancelled"
|
|
||||||
: "error"
|
|
||||||
touch(child, event.created)
|
|
||||||
input.emit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainTool = (item: SessionMessageAssistantTool, active?: Record<string, unknown>) => {
|
|
||||||
if (item.name !== "subagent" || item.state.status !== "completed") return
|
|
||||||
const found = childSessionID(record(item.state.structured))
|
|
||||||
if (!found) return
|
|
||||||
const child = ensureChild(found.sessionID)
|
|
||||||
applyMeta(child, record(item.state.input))
|
|
||||||
if (found.running) child.background = true
|
|
||||||
if (child.status === "running") {
|
|
||||||
const running = found.running && (!active || found.sessionID in active)
|
|
||||||
child.status = running ? "running" : "completed"
|
|
||||||
}
|
|
||||||
touch(child, item.time.completed ?? item.time.created)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
main(event) {
|
|
||||||
if (event.type === "session.tool.input.started") {
|
|
||||||
if (event.data.name === "subagent") pendingCalls.set(event.data.callID, {})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.tool.called") {
|
|
||||||
if (pendingCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type === "session.tool.failed") {
|
|
||||||
pendingCalls.delete(event.data.callID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (event.type !== "session.tool.success") return
|
|
||||||
const pending = pendingCalls.get(event.data.callID)
|
|
||||||
pendingCalls.delete(event.data.callID)
|
|
||||||
const found = childSessionID(record(event.data.structured))
|
|
||||||
if (!found) return
|
|
||||||
const child = ensureChild(found.sessionID)
|
|
||||||
applyMeta(child, pending)
|
|
||||||
if (found.running) {
|
|
||||||
child.background = true
|
|
||||||
child.status = "running"
|
|
||||||
}
|
|
||||||
if (!found.running && child.status === "running") child.status = "completed"
|
|
||||||
touch(child, event.created)
|
|
||||||
input.emit()
|
|
||||||
if (!child.hydrated) void hydrateChild(child)
|
|
||||||
},
|
|
||||||
foreign(sessionID, event) {
|
|
||||||
const child = children.get(sessionID)
|
|
||||||
if (child) {
|
|
||||||
if (hydrations.has(sessionID)) {
|
|
||||||
const buffered = hydrationEvents.get(sessionID) ?? []
|
|
||||||
if (buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event)
|
|
||||||
else hydrationOverflow.add(sessionID)
|
|
||||||
hydrationEvents.set(sessionID, buffered)
|
|
||||||
}
|
|
||||||
reduce(child, event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
discover(sessionID)
|
|
||||||
const buffered = pendingEvents.get(sessionID)
|
|
||||||
if (buffered && buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event)
|
|
||||||
},
|
|
||||||
async hydrate(next) {
|
|
||||||
for (const message of next.messages) {
|
|
||||||
if (message.type !== "assistant") continue
|
|
||||||
for (const item of message.content) {
|
|
||||||
if (item.type === "tool") mainTool(item, next.active)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Family index: adopt children directly from the current session list so
|
|
||||||
// historical subagents beyond the projected message window still get tabs.
|
|
||||||
const family = await input.sdk.session
|
|
||||||
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" })
|
|
||||||
.then((response) => response.data)
|
|
||||||
.catch(() => [])
|
|
||||||
for (const session of family) {
|
|
||||||
const child = ensureChild(session.id)
|
|
||||||
if (session.agent && child.label === FALLBACK_LABEL) child.label = Locale.titlecase(session.agent)
|
|
||||||
if (!child.title) child.title = session.title
|
|
||||||
touch(child, session.time.updated)
|
|
||||||
}
|
|
||||||
for (const sessionID of Object.keys(next.active)) discover(sessionID)
|
|
||||||
for (const child of children.values()) {
|
|
||||||
// Reconnect can miss a child's settled event; the active map is the
|
|
||||||
// authoritative live signal for still-running children.
|
|
||||||
if (child.status === "running" && !(child.sessionID in next.active)) child.status = "completed"
|
|
||||||
}
|
|
||||||
const current = selected ? children.get(selected) : undefined
|
|
||||||
if (current) await hydrateChild(current)
|
|
||||||
if (children.size > 0) input.emit()
|
|
||||||
},
|
|
||||||
select(sessionID) {
|
|
||||||
selected = sessionID
|
|
||||||
const child = sessionID ? children.get(sessionID) : undefined
|
|
||||||
if (child && !child.hydrated) void hydrateChild(child)
|
|
||||||
input.emit()
|
|
||||||
},
|
|
||||||
snapshot() {
|
|
||||||
const tabs = [...children.values()].map(tab).toSorted((a, b) => {
|
|
||||||
const active = Number(b.status === "running") - Number(a.status === "running")
|
|
||||||
if (active !== 0) return active
|
|
||||||
return b.lastUpdatedAt - a.lastUpdatedAt
|
|
||||||
})
|
|
||||||
const child = selected ? children.get(selected) : undefined
|
|
||||||
const details: Record<string, FooterSubagentDetail> = child
|
|
||||||
? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } }
|
|
||||||
: {}
|
|
||||||
return { tabs, details, permissions: [], questions: [] }
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,94 +0,0 @@
|
||||||
// Dev-only JSONL event trace for direct interactive mode.
|
|
||||||
//
|
|
||||||
// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
|
|
||||||
// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
|
|
||||||
// a latest.json pointer so you can quickly find the most recent trace.
|
|
||||||
//
|
|
||||||
// The trace captures the full closed loop: outbound prompts, inbound SDK
|
|
||||||
// events, reducer output, footer commits, and turn lifecycle markers.
|
|
||||||
// Useful for debugging stream ordering, permission behavior, and
|
|
||||||
// footer/transcript mismatches.
|
|
||||||
//
|
|
||||||
// Lazy-initialized: the first call to trace() decides whether tracing is
|
|
||||||
// active based on the env var, and subsequent calls return the cached result.
|
|
||||||
import fs from "fs"
|
|
||||||
import path from "path"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
|
|
||||||
export type Trace = {
|
|
||||||
write(type: string, data?: unknown): void
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: Trace | false | undefined
|
|
||||||
|
|
||||||
function stamp() {
|
|
||||||
return new Date()
|
|
||||||
.toISOString()
|
|
||||||
.replace(/[-:]/g, "")
|
|
||||||
.replace(/\.\d+Z$/, "Z")
|
|
||||||
}
|
|
||||||
|
|
||||||
function file() {
|
|
||||||
return path.join(Global.Path.log, "direct", `${stamp()}-${process.pid}.jsonl`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function latest() {
|
|
||||||
return path.join(Global.Path.log, "direct", "latest.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
function text(data: unknown) {
|
|
||||||
return JSON.stringify(
|
|
||||||
data,
|
|
||||||
(_key, value) => {
|
|
||||||
if (typeof value === "bigint") {
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trace(): Trace | undefined {
|
|
||||||
if (state !== undefined) {
|
|
||||||
return state || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.OPENCODE_DIRECT_TRACE) {
|
|
||||||
state = false
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = file()
|
|
||||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
||||||
fs.writeFileSync(
|
|
||||||
latest(),
|
|
||||||
text({
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
pid: process.pid,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
argv: process.argv.slice(2),
|
|
||||||
path: target,
|
|
||||||
}) + "\n",
|
|
||||||
)
|
|
||||||
state = {
|
|
||||||
write(type: string, data?: unknown) {
|
|
||||||
fs.appendFileSync(
|
|
||||||
target,
|
|
||||||
text({
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
pid: process.pid,
|
|
||||||
type,
|
|
||||||
data,
|
|
||||||
}) + "\n",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
state.write("trace.start", {
|
|
||||||
argv: process.argv.slice(2),
|
|
||||||
cwd: process.cwd(),
|
|
||||||
path: target,
|
|
||||||
})
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
// Model variant resolution and persistence.
|
|
||||||
//
|
|
||||||
// Variants are provider-specific reasoning effort levels (e.g., "high", "max").
|
|
||||||
// Resolution priority: CLI --variant flag > saved preference > session history.
|
|
||||||
//
|
|
||||||
// The saved variant persists across sessions in ~/.local/state/opencode/model.json
|
|
||||||
// so your last-used variant sticks. Cycling (ctrl+t) updates both the active
|
|
||||||
// variant and the persisted file.
|
|
||||||
import path from "path"
|
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { Context, Effect, Layer } from "effect"
|
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
|
||||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
|
||||||
import { Global } from "@opencode-ai/core/global"
|
|
||||||
import { createSession, sessionVariant, type RunSession, type SessionMessages } from "./session.shared"
|
|
||||||
import type { RunInput, RunProvider } from "./types"
|
|
||||||
|
|
||||||
const MODEL_FILE = path.join(Global.Path.state, "model.json")
|
|
||||||
|
|
||||||
type ModelState = Record<string, unknown> & {
|
|
||||||
variant?: Record<string, string | undefined>
|
|
||||||
}
|
|
||||||
type VariantService = {
|
|
||||||
readonly resolveSavedVariant: (model: RunInput["model"]) => Effect.Effect<string | undefined>
|
|
||||||
readonly saveVariant: (model: RunInput["model"], variant: string | undefined) => Effect.Effect<void>
|
|
||||||
}
|
|
||||||
type VariantRuntime = {
|
|
||||||
resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined>
|
|
||||||
saveVariant(model: RunInput["model"], variant: string | undefined): Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Service extends Context.Service<Service, VariantService>()("@opencode/RunVariant") {}
|
|
||||||
|
|
||||||
function modelKey(provider: string, model: string): string {
|
|
||||||
return `${provider}/${model}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function variantKey(model: NonNullable<RunInput["model"]>): string {
|
|
||||||
return modelKey(model.providerID, model.modelID)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function modelInfo(providers: RunProvider[] | undefined, model: NonNullable<RunInput["model"]>) {
|
|
||||||
const provider = providers?.find((item) => item.id === model.providerID)
|
|
||||||
return {
|
|
||||||
provider: provider?.name ?? model.providerID,
|
|
||||||
model: provider?.models[model.modelID]?.name ?? model.modelID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatModelLabel(
|
|
||||||
model: NonNullable<RunInput["model"]>,
|
|
||||||
variant: string | undefined,
|
|
||||||
providers?: RunProvider[],
|
|
||||||
): string {
|
|
||||||
const names = modelInfo(providers, model)
|
|
||||||
const label = variant ? ` · ${variant}` : ""
|
|
||||||
return `${names.model} · ${names.provider}${label}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cycleVariant(current: string | undefined, variants: string[]): string | undefined {
|
|
||||||
if (variants.length === 0) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!current) {
|
|
||||||
return variants[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const idx = variants.indexOf(current)
|
|
||||||
if (idx === -1 || idx === variants.length - 1) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return variants[idx + 1]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickVariant(model: RunInput["model"], input: RunSession | SessionMessages): string | undefined {
|
|
||||||
return sessionVariant(Array.isArray(input) ? createSession(input) : input, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitVariant(value: string | undefined, variants: string[]): string | undefined {
|
|
||||||
if (!value) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variants.length === 0 || variants.includes(value)) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// Picks the active variant. CLI flag wins, then saved preference, then session
|
|
||||||
// history. fitVariant() checks saved and session values against the available
|
|
||||||
// variants list -- if the provider doesn't offer a variant, it drops.
|
|
||||||
export function resolveVariant(
|
|
||||||
input: string | undefined,
|
|
||||||
session: string | undefined,
|
|
||||||
saved: string | undefined,
|
|
||||||
variants: string[],
|
|
||||||
): string | undefined {
|
|
||||||
if (input !== undefined) {
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = fitVariant(saved, variants)
|
|
||||||
const current = fitVariant(session, variants)
|
|
||||||
if (current !== undefined) {
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function state(value: unknown): ModelState {
|
|
||||||
if (!isRecord(value)) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const variant = isRecord(value.variant)
|
|
||||||
? Object.fromEntries(
|
|
||||||
Object.entries(value.variant).flatMap(([key, item]) => {
|
|
||||||
if (typeof item !== "string") {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return [[key, item] as const]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return {
|
|
||||||
...value,
|
|
||||||
variant,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = Layer.fresh(
|
|
||||||
Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const file = yield* FSUtil.Service
|
|
||||||
|
|
||||||
const read = Effect.fn("RunVariant.read")(function* () {
|
|
||||||
return yield* file.readJson(MODEL_FILE).pipe(
|
|
||||||
Effect.map(state),
|
|
||||||
Effect.catchCause(() => Effect.succeed(state(undefined))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) {
|
|
||||||
if (!model) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return (yield* read()).variant?.[variantKey(model)]
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveVariant = Effect.fn("RunVariant.saveVariant")(function* (
|
|
||||||
model: RunInput["model"],
|
|
||||||
variant: string | undefined,
|
|
||||||
) {
|
|
||||||
if (!model) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = yield* read()
|
|
||||||
const next = {
|
|
||||||
...current.variant,
|
|
||||||
}
|
|
||||||
const key = variantKey(model)
|
|
||||||
if (variant) {
|
|
||||||
next[key] = variant
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!variant) {
|
|
||||||
delete next[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* file
|
|
||||||
.writeJson(MODEL_FILE, {
|
|
||||||
...current,
|
|
||||||
variant: next,
|
|
||||||
})
|
|
||||||
.pipe(Effect.orElseSucceed(() => undefined))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({
|
|
||||||
resolveSavedVariant,
|
|
||||||
saveVariant,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] })
|
|
||||||
|
|
||||||
/** @internal Exported for testing. */
|
|
||||||
export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime {
|
|
||||||
const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements))
|
|
||||||
return {
|
|
||||||
resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined),
|
|
||||||
saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runtime = createVariantRuntime()
|
|
||||||
|
|
||||||
export async function resolveSavedVariant(model: RunInput["model"]): Promise<string | undefined> {
|
|
||||||
return runtime.resolveSavedVariant(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveVariant(model: RunInput["model"], variant: string | undefined): void {
|
|
||||||
void runtime.saveVariant(model, variant)
|
|
||||||
}
|
|
||||||
9
packages/cli/src/node/index.ts
Normal file
9
packages/cli/src/node/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import "./plugin-runtime.promise"
|
||||||
|
import "./plugin-runtime.effect"
|
||||||
|
|
||||||
|
process.stdout.on("error", (error) => {
|
||||||
|
if ("code" in error && error.code === "EPIPE") return
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
await import("../index")
|
||||||
28
packages/cli/src/node/plugin-runtime.effect.ts
Normal file
28
packages/cli/src/node/plugin-runtime.effect.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import {
|
||||||
|
Agent,
|
||||||
|
Command,
|
||||||
|
Connection,
|
||||||
|
Credential,
|
||||||
|
Integration,
|
||||||
|
Model,
|
||||||
|
Plugin,
|
||||||
|
Provider,
|
||||||
|
Reference,
|
||||||
|
Skill,
|
||||||
|
} from "@opencode-ai/plugin/v2/effect"
|
||||||
|
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||||
|
|
||||||
|
const key = Symbol.for("opencode.plugin.v2.effect")
|
||||||
|
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||||
|
Agent,
|
||||||
|
Command,
|
||||||
|
Connection,
|
||||||
|
Credential,
|
||||||
|
Integration,
|
||||||
|
Model,
|
||||||
|
Plugin,
|
||||||
|
Provider,
|
||||||
|
Reference,
|
||||||
|
Skill,
|
||||||
|
Tool,
|
||||||
|
}
|
||||||
26
packages/cli/src/node/plugin-runtime.promise.ts
Normal file
26
packages/cli/src/node/plugin-runtime.promise.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import {
|
||||||
|
Agent,
|
||||||
|
Command,
|
||||||
|
Connection,
|
||||||
|
Credential,
|
||||||
|
Integration,
|
||||||
|
Model,
|
||||||
|
Plugin,
|
||||||
|
Provider,
|
||||||
|
Reference,
|
||||||
|
Skill,
|
||||||
|
} from "@opencode-ai/plugin/v2"
|
||||||
|
|
||||||
|
const key = Symbol.for("opencode.plugin.v2.promise")
|
||||||
|
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||||
|
Agent,
|
||||||
|
Command,
|
||||||
|
Connection,
|
||||||
|
Credential,
|
||||||
|
Integration,
|
||||||
|
Model,
|
||||||
|
Plugin,
|
||||||
|
Provider,
|
||||||
|
Reference,
|
||||||
|
Skill,
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue