Compare commits

..

56 Commits

Author SHA1 Message Date
Aiden Cline 481defdf4e fix(ai): disable bedrock mantle responses websockets 2026-08-25 22:23:11 -05:00
Aiden Cline 5add6a8e19 fix(ai): preserve provider-defined responses item ids (#45094) 2026-08-25 22:17:16 -05:00
Aiden Cline f3c390b89e feat(ai): add native DeepInfra provider (#45108) 2026-08-25 22:15:52 -05:00
opencode-agent[bot] 185c3e5136 fix(app): show project logos throughout settings (#45134)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 02:43:53 +00:00
Kit Langton cb863b8ea5 fix(tui): dismiss the active interaction with ctrl-c (#45111) 2026-08-25 22:38:50 -04:00
Kit Langton 695c043e6b feat(core): refresh unpinned plugins on startup (#45118) 2026-08-25 22:32:05 -04:00
opencode-agent[bot] d53456da3b chore: update nix node_modules hashes 2026-08-26 02:16:23 +00:00
opencode-agent[bot] 64e930628d fix(ci): compare affected packages against actual PR base (#45130)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-26 10:13:53 +08:00
Kit Langton 3c73ce1dc7 fix(core): materialize mentioned skills on prompts (#44840) 2026-08-25 22:04:09 -04:00
Kit Langton 4b71ae6a0d feat(core): support git plugin packages (#45110) 2026-08-25 22:02:49 -04:00
opencode-agent[bot] e211b6f30e test: run only affected unit suites (#45034)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 09:51:44 +08:00
Kit Langton ab4621e437 fix(sdk): keep packaged Effect runtime coherent (#45122) 2026-08-26 01:51:36 +00:00
Kit Langton 97ba700fac fix(tui): preserve prompt metadata visibility across sessions (#45116) 2026-08-25 21:37:58 -04:00
Luke Parker 03fb5c6c67 fix(app): prevent clipped virtual timeline rows (#45115) 2026-08-26 01:33:32 +00:00
Luke Parker 5a54eb4afc fix(app): stream running shell tool output (#45106) 2026-08-26 00:58:15 +00:00
Kit Langton c4dcf72e13 fix(tui): detect clipped transcript bottom (#45100) 2026-08-25 20:46:52 -04:00
opencode-agent[bot] 27e0de6b23 chore: update nix node_modules hashes 2026-08-26 00:41:39 +00:00
Kit Langton 73d7b1d4c1 fix(tui): preserve interrupted Mermaid diagrams (#45102) 2026-08-26 00:25:00 +00:00
Kit Langton 690ad8e8bd test(core): isolate host configuration and credentials (#44845) 2026-08-25 20:22:21 -04:00
Aiden Cline 6c97be6974 feat(ai): add native Cerebras and Together AI providers (#45098) 2026-08-25 19:20:47 -05:00
Dax Raad 6cd1ffac50 chore: synchronize bun lockfile 2026-08-25 19:18:17 -04:00
Aiden Cline f08c234890 fix(ai): ignore SSE retry directives without ending streams (#45093) 2026-08-25 18:17:45 -05:00
Kit Langton 7f2b052db6 refactor(core): remove unused Drizzle migration framework 2026-08-25 19:16:26 -04:00
Dax Raad 297a3328c6 feat(tui): group MCP integrations in connection dialog 2026-08-25 19:14:12 -04:00
Kit Langton 7f9e5e91ab feat(tui): add experimental session preview tabs (#45021) 2026-08-25 18:51:36 -04:00
Aiden Cline 3726e3254d fix(ai): enable Vertex Anthropic prompt caching (#45088) 2026-08-25 17:44:32 -05:00
Aiden Cline 24605d048f fix(ai): send responses instructions at top level (#45085) 2026-08-25 17:34:40 -05:00
Aiden Cline 0a84625618 fix(ai): accept responses calls without item ids (#45081) 2026-08-25 17:29:57 -05:00
Major Hayden 2e7f06a155 fix(ai): preserve Vertex Anthropic tool continuations (#43498)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-25 17:22:51 -05:00
Aiden Cline c2a3b813a0 fix(ai): require reasoning fields for deepseek assistants (#45075) 2026-08-25 17:13:17 -05:00
opencode-agent[bot] b79cad5ec8 fix(core): respect automatic compaction opt-out on overflow (#45036)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-25 16:58:39 -05:00
opencode-agent[bot] 61dd296161 fix(www): generate CLI schema without tracking it (#45080)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 23:54:07 +02:00
Filip 66a790c624 chore(www): regenerate documentation artifacts (#45077) 2026-08-25 23:33:01 +02:00
Aiden Cline 0ae3aac317 fix(ai): replay responses history independently of storage (#45050) 2026-08-25 16:28:22 -05:00
Dax eb1ac54d73 feat(tui): support multiple integration accounts (#45072) 2026-08-25 17:03:16 -04:00
opencode-agent[bot] d2100a51f1 feat(cli): restore debug paths command (#45063)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-25 23:01:43 +02:00
opencode-agent[bot] d4803ffe38 feat(cli): publish generated CLI configuration schema (#45070)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:49:45 +02:00
Kit Langton db7837814c fix(project): refresh renamed projects across live clients (#45043) 2026-08-25 20:13:10 +00:00
opencode-agent[bot] 543a4f4912 fix(ci): preserve required e2e matrix checks (#45065)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:11:43 +02:00
opencode-agent[bot] d562f6df1e fix(core): recover legacy database migration history (#45062)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:02:26 +02:00
Aiden Cline 263a442a6c fix(ai): normalize chat tool call ids (#45056) 2026-08-25 14:39:00 -05:00
Filip 82947af8e3 fix(core): ignore SSE comment heartbeats (#43626) 2026-08-25 21:34:08 +02:00
Filip e1afdaac52 fix(core): fall back on oversized websocket requests v2 (#43100) 2026-08-25 21:27:08 +02:00
Aiden Cline a5829431b0 fix(ai): bridge tool results for mistral family models (#45051) 2026-08-25 13:40:35 -05:00
Aiden Cline 1ca82d154c fix(ai): omit empty chat assistant messages (#45046) 2026-08-25 12:56:24 -05:00
James Long bc1f67e518 feat(cli): embed persistent PTY service binaries (#44970) 2026-08-25 13:19:48 -04:00
Kit Langton 1f45962c84 fix(tui): support inline session rename and title regeneration (#45023) 2026-08-25 13:10:22 -04:00
Kit Langton 88242e21a8 fix(tui): stop completed exploration groups from spinning 2026-08-25 13:09:03 -04:00
opencode-agent[bot] 0fd719067d feat(plugin): add permission review hooks (#45003)
Co-authored-by: R44VC0RP <R44VC0RP@users.noreply.github.com>
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-25 16:57:17 +00:00
Aiden Cline c94a4913c0 fix(ai): preserve unencrypted reasoning history (#45032) 2026-08-25 11:44:44 -05:00
opencode-agent[bot] 6c32ba81e2 test: skip unaffected app e2e tests (#45030)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 00:33:14 +08:00
Kit Langton 938a82226a test(tui): synchronize tab close frame before pointer moves (#45017) 2026-08-25 12:30:20 -04:00
Aiden Cline c46b76b58e fix(ai): route response events by output index (#45013) 2026-08-25 11:27:40 -05:00
opencode-agent[bot] fbb3730fdd chore: update nix node_modules hashes 2026-08-25 16:26:05 +00:00
opencode-agent[bot] 96cff7bb7a fix(app): clarify context usage label (#45012)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 00:20:00 +08:00
Kit Langton 578f8d637a fix(server): pin shared Effect platform runtime (#45016) 2026-08-25 12:19:24 -04:00
235 changed files with 9573 additions and 1350 deletions
+59 -7
View File
@@ -22,6 +22,36 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
affected:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -41,6 +71,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -80,9 +111,16 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
GITHUB_ACTIONS=false bun turbo test
exit 0
fi
GITHUB_ACTIONS=false bun turbo test --affected
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify published codemode package
if: runner.os == 'Linux'
@@ -92,8 +130,15 @@ jobs:
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
working-directory: packages/sdk
run: bun run verify:package
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo verify:package --filter=@opencode-ai/sdk
exit 0
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify compiled service lifecycle
if: always()
@@ -133,7 +178,7 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
needs: affected
strategy:
fail-fast: false
matrix:
@@ -144,32 +189,38 @@ jobs:
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2' }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
steps:
- name: Checkout repository
if: env.E2E_ENABLED == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
if: env.E2E_ENABLED == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
if: env.E2E_ENABLED == 'true'
uses: ./.github/actions/setup-bun
- name: Read Playwright version
if: env.E2E_ENABLED == 'true'
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: env.E2E_ENABLED == 'true'
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -177,23 +228,24 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:local
env:
CI: true
timeout-minutes: 30
- name: Upload Playwright artifacts
if: always()
if: always() && env.E2E_ENABLED == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
+2 -15
View File
@@ -346,9 +346,7 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
@@ -357,7 +355,6 @@
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@ff-labs/fff-bun": "0.10.5",
@@ -667,6 +664,7 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -973,6 +971,7 @@
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@npmcli/arborist": "catalog:",
"@npmcli/config": "10.8.1",
"@opentelemetry/api": "1.9.0",
@@ -1178,8 +1177,6 @@
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8pcrQvEQCbvrrQKnD6hclBbI0hUgSrgyADykRbabxv/g9vPurfMC6n23J7dD+KZ3EcCoW+qz3IUIfySJ58gBOg=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZgkausouWvO9U4ZtowNJ093bSNYOvH8zqls3uLC3+oxzWvbbTZO8SOdmFk0+gGafsXFJvq2yUX9+rEeJPwOJLw=="],
@@ -1206,8 +1203,6 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k3p9e3k0/gpDDyTtvafsK4HYR4D/aUQW/kzCwWo1+CzdBU84i4L14gWISC/mv6tgSicMXHcEUd521fPufQwNlg=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.123", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.69", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WNASvd1C516oh2qYIj9EvAVPdU+Abads8DQWU6p9lQtvFFeGh8QW+3LDOARZd1GCINUFfw5yadEK845SMQKLsA=="],
@@ -5912,10 +5907,6 @@
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -5954,10 +5945,6 @@
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-rIg+QSwF0kmPRnlPrpV7IlPTrHAxvoBOjdtoFOcUnKc=",
"aarch64-linux": "sha256-74rMnqiGy7gAUDWr9HWBc6Sn3kxh/hDfm5kp+I4fjok=",
"aarch64-darwin": "sha256-ImASKYxUQDHzH/UuwXUBsMNkitAH5wDYESlevEy9qeA=",
"x86_64-darwin": "sha256-txPhXfZOSoQdNXPfddKSdKqdjEWrb9GkBb38h+pZIuo="
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
}
}
+5 -5
View File
@@ -157,9 +157,9 @@ const PROVIDERS: ReadonlyArray<Provider> = [
id: "togetherai",
label: "TogetherAI",
tier: "compatible",
note: "Existing OpenAI-compatible text/tool recorded tests",
vars: [{ name: "TOGETHER_AI_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
note: "Native Together AI text/tool recorded tests",
vars: [{ name: "TOGETHER_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_API_KEY)),
},
{
id: "minimax",
@@ -200,8 +200,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
{
id: "cerebras",
label: "Cerebras",
tier: "optional",
note: "OpenAI-compatible bridge",
tier: "compatible",
note: "Native Cerebras text/tool/tool-loop recorded tests",
vars: [{ name: "CEREBRAS_API_KEY" }],
validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)),
},
+1 -1
View File
@@ -36,7 +36,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "google-vertex-messages", "bedrock-converse", "openrouter"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -744,9 +744,12 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
}
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
const previous = messages[index - 1]
const next = messages[index + 1]
const canUseNativeSystemUpdate = (request: LLMRequest, index: number) => {
const previous = request.messages[index - 1]
const next = request.messages[index + 1]
// Vertex currently rejects/404s for a system message after local tool results,
// so fold it into the user tool-result turn across continuations and history.
if (request.model.route.id === "google-vertex-messages" && previous?.role === "tool") return false
return (
previous !== undefined &&
previous.role !== "system" &&
@@ -793,7 +796,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (message.role === "system") {
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}
+143 -114
View File
@@ -79,10 +79,60 @@ const OpenResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
const OpenResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
})
const OpenResponsesWebSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
)
const OpenResponsesFileSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("file_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
queries: Schema.optional(Schema.Array(Schema.String)),
results: optionalNull(Schema.Array(JsonObject)),
}),
[JsonObject],
)
const OpenResponsesCodeInterpreterCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("code_interpreter_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
code: optionalNull(Schema.String),
container_id: optionalNull(Schema.String),
outputs: optionalNull(Schema.Array(JsonObject)),
}),
[JsonObject],
)
const OpenResponsesMCPCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("mcp_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
server_label: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
output: optionalNull(Schema.String),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
)
export const HostedToolItem = Schema.Union([
OpenResponsesWebSearchCall,
OpenResponsesFileSearchCall,
OpenResponsesCodeInterpreterCall,
OpenResponsesMCPCall,
])
export type HostedToolItem = Schema.Schema.Type<typeof HostedToolItem>
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images and files in addition to text.
@@ -111,7 +161,6 @@ export const InputItem = Schema.Union([
phase: Schema.optionalKey(MessagePhase),
}),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
@@ -124,10 +173,17 @@ export const InputItem = Schema.Union([
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -140,7 +196,7 @@ type LoweredInputItem =
// multiple streamed summary parts into the same item before flushing.
type OpenResponsesReasoningInput = {
type: "reasoning"
id: string
id?: string
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
}
@@ -288,6 +344,7 @@ export const Event = Schema.StructWithRest(
arguments: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -315,9 +372,6 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
// Which lowered input item a persisted item id is about to be attached to.
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
export interface Extension {
readonly id: string
readonly name: string
@@ -326,10 +380,7 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
// Optional grammar check applied before a persisted item id is resent as
// part of replayed history. Returning false drops the id; every lowered
// item treats a dropped id the same as an absent one.
readonly acceptsItemID?: (kind: ItemKind, id: string) => boolean
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -341,10 +392,10 @@ export interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
@@ -389,53 +440,37 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
// Servers validate item ids on replayed history, and a malformed or oversized
// id can fail an otherwise valid request. Only server-issued tokens are worth
// resending; anything else is treated as absent so the item is resent without
// an id (or skipped, for items that cannot be expressed without one).
const ITEM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
// Server-issued item ids need a nonempty prefix and suffix, but the prefix is
// provider-defined and does not necessarily identify the item's semantic type.
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) &&
typeof metadata.itemId === "string" &&
ITEM_ID_PATTERN.test(metadata.itemId)
? metadata.itemId
: undefined
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined
const separator = metadata.itemId.indexOf("_")
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
}
const acceptsItemID = (extension: Extension, kind: ItemKind, id: string | undefined): id is string =>
id !== undefined && (extension.acceptsItemID?.(kind, id) ?? true)
const lowerToolCall = (
part: ToolCallPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesInputItem => {
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
...(acceptsItemID(extension, "function-call", id) ? { id } : {}),
...(id === undefined ? {} : { id }),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
extension: Extension,
): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
if (!ProviderShared.isRecord(metadata) || !acceptsItemID(extension, "reasoning", id)) return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id,
...(id === undefined ? {} : { id }),
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
@@ -526,10 +561,7 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
@@ -552,16 +584,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const hostedToolItems = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const rawID = itemID(part.providerMetadata, providerMetadataKey)
const id = acceptsItemID(extension, "message", rawID) ? rawID : undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
@@ -586,51 +616,51 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
continue
}
reasoningItems[reasoning.id] = reasoning
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
input.push(reasoning)
continue
}
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey, extension))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
const hosted =
part.result.type !== "json"
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
: extension.lowerHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
hostedToolItems.add(id)
}
continue
}
if (reference) hostedToolReferences.add(reference)
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -655,22 +685,16 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
}
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
return input
})
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const instructions = ProviderShared.joinText(request.system)
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
...(instructions ? { instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
@@ -818,6 +842,9 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
@@ -893,25 +920,23 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
events,
]
}
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
const metadata = item.id ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id,
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
providerMetadata: metadata,
}),
},
[
...events,
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
],
[...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })],
]
}
@@ -966,31 +991,21 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
[event.summary_index]: "can-conclude",
},
},
},
},
events,
NO_EVENTS,
]
}
@@ -1053,18 +1068,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const id = item.id ?? item.call_id
const tools = state.tools[id]
? state.tools
: ToolStream.start(state.tools, item.id, {
: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name,
providerMetadata: providerMetadata(state, { itemId: item.id }),
providerMetadata: item.id ? providerMetadata(state, { itemId: item.id }) : undefined,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
@@ -1118,10 +1134,11 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
event.response?.output ?? [],
() => [state, NO_EVENTS] satisfies StepResult,
([current, events], item) => {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (
!item.id ||
((item.type !== "function_call" || !current.tools[item.id]) &&
(item.type !== "reasoning" || !current.reasoningItems[item.id]))
!id ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
@@ -1201,7 +1218,11 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, event: Event) => {
export const step = (state: ParserState, input: Event) => {
const event =
input.item_id && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1243,7 +1264,15 @@ export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
),
)
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id
@@ -1278,10 +1307,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set<string>(),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
export const protocol = Protocol.make({
+50 -17
View File
@@ -278,6 +278,7 @@ interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
readonly toolCallID?: (id: string) => string
}
const lowerTool = (
@@ -304,8 +305,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "function" as const, function: { name } }),
})
const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
id: part.id,
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
id: options.toolCallID?.(part.id) ?? part.id,
type: "function",
function: {
name: part.name,
@@ -363,8 +364,9 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField?: string,
options: LoweringOptions = {},
configuredField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -381,7 +383,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part))
toolCalls.push(lowerToolCall(part, options))
continue
}
}
@@ -391,15 +393,17 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (configuredField !== undefined) return configuredField
if (reasoning.length === 0) return undefined
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
return configuredField
if (reasoning.length === 0) return requireReasoning ? "reasoning_content" : undefined
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
if (!fullyStructured || requireReasoning) return "reasoning_content"
})()
const reasoningText = (() => {
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
if (reasoning.length === 0) return nativeReasoning
if (configuredField !== undefined)
return reasoning.length === 0 ? (nativeReasoning ?? (requireReasoning ? "" : undefined)) : text
if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined)
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
@@ -427,7 +431,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: part.id,
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
@@ -437,7 +441,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: part.id,
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
@@ -453,11 +457,13 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField?: string,
options: LoweringOptions = {},
reasoningField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
if (message.role === "assistant")
return [yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)]
return (yield* lowerToolMessages(message, options)).messages
})
@@ -478,12 +484,37 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const modelID = request.model.id.toLowerCase()
const requireReasoning =
request.model.compatibility?.requireReasoning ??
(request.model.compatibility?.reasoningField !== undefined ||
request.model.provider === "deepseek" ||
request.model.route.endpoint.baseURL?.toLowerCase().includes("deepseek.com") ||
modelID.includes("deepseek"))
const reasoningField = request.model.compatibility?.reasoningField
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
const lowering = {
...options,
toolCallID: (id: string) => {
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
return id.slice(0, 40)
return id
},
}
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
if (pendingImages.length === 0) return
bridgeTools()
messages.push({ role: "user", content: pendingImages.splice(0) })
}
for (const message of request.messages) {
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
@@ -526,14 +557,16 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
continue
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, options)
const lowered = yield* lowerToolMessages(message, lowering)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
messages.push(...(yield* lowerMessage(message, reasoningField, requireReasoning, lowering)))
}
flushImages()
return messages
+44 -23
View File
@@ -7,7 +7,7 @@ import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { optionalArray, ProviderShared } from "./shared.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -32,6 +32,40 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("computer_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
pending_safety_checks: Schema.optional(Schema.Array(JsonObject)),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_preview_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: optionalNull(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
revised_prompt: optionalNull(Schema.String),
}),
[JsonObject],
),
])
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
const OpenAIResponsesToolChoice = Schema.Union([
@@ -41,6 +75,7 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -51,28 +86,10 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
// Replayed items are paired with stored server state by id, so a foreign or
// synthetic token can fail request validation even when `call_id` pairing is
// intact. Only resend ids in each item kind's own grammar; hosted tool
// references keep generic validation because every hosted tool mints its own
// prefix. The same allowlist approach codex uses before resending history
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
message: ["msg_"],
reasoning: ["rs_"],
"function-call": ["fc_"],
// Every hosted tool mints its own id prefix, so references keep generic
// validation only.
reference: [],
}
const extension = {
id: ADAPTER,
name: NAME,
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
const prefixes = ITEM_ID_PREFIXES[kind]
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
},
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -105,6 +122,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
: { type: "function" as const, name },
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
@@ -112,7 +131,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return {
return yield* decodeBody({
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
@@ -123,7 +142,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
} satisfies OpenAIResponsesBody
})
})
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
@@ -166,7 +185,9 @@ const HOSTED_TOOLS = {
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
+20 -7
View File
@@ -210,10 +210,9 @@ export const errorText = (error: unknown) => {
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`.
* element. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
@@ -221,9 +220,23 @@ export const sseFraming = (
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", () => Stream.empty),
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
Stream.mapAccumEffect(
() => {
const output: Sse.Event[] = []
return {
output,
parser: Sse.makeParser((event) => {
if (event._tag === "Event") output.push(event)
}),
}
},
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error) return yield* eventError("sse", error.message)
return [state, state.output.splice(0)] as const
}),
),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
@@ -56,7 +56,6 @@ export const StreamOptions = Schema.Struct({
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
+41 -1
View File
@@ -1,15 +1,52 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
const XAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("x_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
),
])
const XAIResponsesBody = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])),
stream: Schema.Literal(true),
})
const extension = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
@@ -35,7 +72,10 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
export const protocol = Protocol.make({
id: ADAPTER,
body: OpenResponses.protocol.body,
body: {
schema: XAIResponsesBody,
from: fromRequest,
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
@@ -29,6 +29,11 @@ export interface Settings extends ProviderPackage.Settings {
const responsesRoute = OpenAIResponses.route.with({
id: "bedrock-mantle-responses",
provider: id,
transport: OpenAIResponses.channelTransport({
id: "bedrock-mantle-responses",
name: "Bedrock Mantle Responses",
enabled: () => false,
}),
})
const chatRoute = OpenAIChat.route.with({
+58
View File
@@ -0,0 +1,58 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cerebras")
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = OpenAICompatibleChat.route.with({
id: "cerebras-chat",
provider: id,
endpoint: { baseURL: profiles.cerebras.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.cerebras.baseURL },
auth: AuthOptions.bearer(input, "CEREBRAS_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
+61
View File
@@ -0,0 +1,61 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("deepinfra")
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = OpenAICompatibleChat.route.with({
id: "deepinfra-chat",
provider: id,
endpoint: { baseURL: profiles.deepinfra.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const root = baseURL?.replace(/\/+$/, "")
const configured = route.with({
...defaults,
endpoint: {
baseURL: root === undefined ? profiles.deepinfra.baseURL : root.endsWith("/openai") ? root : `${root}/openai`,
},
auth: AuthOptions.bearer(input, "DEEPINFRA_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
+3
View File
@@ -3,8 +3,10 @@ export * as AnthropicCompatible from "./anthropic-compatible.js"
export * as AmazonBedrock from "./amazon-bedrock.js"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
export * as Azure from "./azure.js"
export * as Cerebras from "./cerebras.js"
export * as Cloudflare from "./cloudflare.js"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
export * as DeepInfra from "./deepinfra.js"
export * as Google from "./google.js"
export * as GoogleVertex from "./google-vertex.js"
export * as GoogleVertexChat from "./google-vertex-chat.js"
@@ -14,5 +16,6 @@ export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenRouter from "./openrouter.js"
export * as TogetherAI from "./togetherai.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
+58
View File
@@ -0,0 +1,58 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("togetherai")
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = OpenAICompatibleChat.route.with({
id: "togetherai-chat",
provider: id,
endpoint: { baseURL: profiles.togetherai.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.togetherai.baseURL },
auth: AuthOptions.bearer(input, ["TOGETHER_API_KEY", "TOGETHER_AI_API_KEY"]),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
+3
View File
@@ -153,8 +153,11 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
)({
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
/** Require every assistant message to include its reasoning field, even when empty. */
requireReasoning: Schema.optional(Schema.Boolean),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
+22 -1
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src/index.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AmazonBedrock } from "../src/providers.js"
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
import * as Gemini from "../src/protocols/gemini.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -86,6 +86,27 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' emits Anthropic cache markers on Vertex", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-4-8",
),
system: "You are concise.",
tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"provider": "cerebras",
"route": "cerebras-chat",
"transport": "http",
"model": "gpt-oss-120b",
"tags": ["prefix:cerebras-chat", "provider:cerebras", "text", "golden"],
"name": "cerebras-chat/cerebras-gpt-oss-120b-text",
"recordedAt": "2026-08-25T23:55:27.619Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cerebras.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":256}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"The\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" user says: \\\"Reply exactly with: Hello!\\\" So we must output exactly\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" \\\"Hello!\\\" with no extra characters, no formatting\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\". Ensure no extra spaces or new\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"lines? Probably just \\\"Hello!\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" Usually we output exactly that.\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"content\":\"Hello!\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":142,\"completion_tokens\":58,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":46},\"prompt_tokens\":84,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.645281,\"queue_time\":0.003817115,\"prompt_time\":0.001587193,\"completion_time\":0.029805929,\"total_time\":0.036823272705078125}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"provider": "cerebras",
"route": "cerebras-chat",
"transport": "http",
"model": "gpt-oss-120b",
"tags": ["prefix:cerebras-chat", "provider:cerebras", "tool", "tool-call", "golden"],
"name": "cerebras-chat/cerebras-gpt-oss-120b-tool-call",
"recordedAt": "2026-08-25T23:55:28.454Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cerebras.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":512}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"We\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\" need to call the function get_weather with city \\\"\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"Paris\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"},\"type\":\"function\",\"id\":\"3d860cefe\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"type\":\"function\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":174,\"completion_tokens\":37,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":13},\"prompt_tokens\":137,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.8019717,\"queue_time\":0.31196235,\"prompt_time\":0.005234764,\"completion_time\":0.020198402,\"total_time\":0.702225923538208}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"provider": "deepinfra",
"route": "deepinfra-chat",
"transport": "http",
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "text", "golden"],
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-text",
"recordedAt": "2026-08-26T00:34:03.019Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hello\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"!\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":25,\"total_tokens\":28,\"completion_tokens\":3,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RbZ8MyY5pos2MShihSmoXoRe\",\"object\":\"chat.completion.chunk\",\"created\":1787704442,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":25,\"total_tokens\":28,\"completion_tokens\":3,\"estimated_cost\":3.46e-6,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"provider": "deepinfra",
"route": "deepinfra-chat",
"transport": "http",
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "tool", "tool-call", "golden"],
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-tool-call",
"recordedAt": "2026-08-26T00:34:04.173Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_SMfBjXa8eCmHLyjfeyARxe3a\",\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\",\"name\":\"get_weather\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":230,\"total_tokens\":244,\"completion_tokens\":14,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RFOwhlB2PhZrgMLviGrx5BQf\",\"object\":\"chat.completion.chunk\",\"created\":1787704443,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":230,\"total_tokens\":244,\"completion_tokens\":14,\"estimated_cost\":0.000027480000000000005,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -0,0 +1,50 @@
{
"version": 1,
"metadata": {
"provider": "deepinfra",
"route": "deepinfra-chat",
"transport": "http",
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
"tags": ["prefix:deepinfra-chat", "provider:deepinfra", "tool", "tool-loop", "golden"],
"name": "deepinfra-chat/deepinfra-llama-3-3-70b-tool-loop",
"recordedAt": "2026-08-26T00:34:05.656Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\\\"}\",\"name\":\"get_weather\"},\"type\":\"function\"}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":243,\"total_tokens\":257,\"completion_tokens\":14,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RxlHFSnlLbUUz6XSxqBQj7TC\",\"object\":\"chat.completion.chunk\",\"created\":1787704444,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":243,\"total_tokens\":257,\"completion_tokens\":14,\"estimated_cost\":0.000028780000000000002,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.deepinfra.com/v1/openai/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning_content\":\"\"},{\"role\":\"tool\",\"tool_call_id\":\"call_W3stxe7FNHozlB4tDxlTVZou\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"service_tier\":null,\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Paris\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" is\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\" sunny\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\".\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":null,\"tool_calls\":null},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":276,\"total_tokens\":281,\"completion_tokens\":5,\"estimated_cost\":null,\"prompt_tokens_details\":null}}\n\ndata: {\"service_tier\":\"default\",\"id\":\"chatcmpl-RHESVChSFPybgK1eUj2cLLY0\",\"object\":\"chat.completion.chunk\",\"created\":1787704445,\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"choices\":[],\"usage\":{\"prompt_tokens\":276,\"total_tokens\":281,\"completion_tokens\":5,\"estimated_cost\":0.0000292,\"prompt_tokens_details\":null}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\"}"
},
{
"direction": "server",
@@ -62,7 +62,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\"}"
},
{
"direction": "server",
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
@@ -91,7 +91,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true,\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"}"
},
"response": {
"status": 200,
@@ -18,7 +18,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true,\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"}"
},
"response": {
"status": 200,
@@ -25,7 +25,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}"
},
"response": {
"status": 200,
@@ -43,7 +43,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0ad67c31d9ddad95016a869efbd02487d1a51eed850e6f87f5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqhp79U9UPKmTdmo9tmdil0C2KXpkFqUc4MNkYHT53Lzos9omncFPg76QzUmmSOdBcajisWBEo-xiTCvhp135uACUq8TJcdw4DluieYq6dWszijy28PFFfeO-6MmHwi7zeln1Z202zErJUEyuf1bML68VAeam5PqlMLG-a4-pmnWiH2ExWKibTUX37QoMQoArrkccJOCmxwDflV_kWDPMFxQVDfeMg9fd1gVv2u-x1Mjk0b9mJDOq0Fe5Gh-IkpWzfXgZTdptFmCM75cksvs61Rqsx6P33czal-LSixEF0WMizCvbMQmqKGs7MKGMeoa6j6vWOnB3ICIbv6FShnSaTpZWJFwejvOurkfuxa-2q6xVDZsBoQCgMWPHsqLxwAo1JKdfBk0pMvSuvpw2BRxykUZ1ULCYJ-BypST65292-EuSZFIuXPMPir-_raSCTsgsZNMscDG6ll3qksDTDS6_o5NutD7Ra-WZzaUe_HQlSLKLACTc4qv2EK1QoC4aYv4goxkTSx17WhS2D86lILgkUd-TIHjJ6iR3uxSNx7YeBNxiJgddIAEjAaSrdF-WDouSNT9k3efd5HhT3zahIOMKgb3XIQzFOYWfWgea5-SbaIdKwne9hU0QyhcBQs6yoifSg-fJZtahbPb-GCDYnOLlH-bV94vldoccb-2P1JdB3jaLj5tJUecfr2H4qiu8MgkPj0TkwYNbJynYmJo9H5Lm-XJ9gfzIXzJh0arKwsS4gwDLf4J3LOEF3WEW3mknOjjb9PrLmHRYXQQh9tTiX9ILPZpbufkyCurTUMQgWiSCitXBC6FoLXRHilSmb-6_avBnlUMziMfey-FkKvRfiPox6BaJrnOq6SGlOv11y7EKvzrn29la7HKPygYenDAkyq2mq0Zk2nLWNmJcv9sQTBrkBdFMmJYPi2J2im8XD5MmAjEL8R4FCBHoPIIZ6pENQykvH8PhpWKuzF5gJlY3Vwz4iJ0Qb9TrNI0hzBoI1U0LeB5FJ2HgjZQwCFF5x3ubh72xrUsFpuyYyYPa8GDT0Bo-LW_IlJ_mN4EwI5Nk9n-8Bt015yxsfpa5YaDeCeQFcdj8SD0UAd7QWtGACpzKcIj1-vJJU7OiwscV_v1dLvoiEe1ehI9jcvPn28TgHlo_dippe0iMN4FAm1Bf8vtWVMFDvfV1rPv1pAFFnSa9XqFszD5Exo_xzcQEoKXvQv3OnUtoiM4Db4uadClazLjoep2TQgHcJBVbTbLySTVPmok4ROFQZsU_mq4vu2M__d8HOjADfIIYz5VLVQKNpo0Hv_QkT2bn56Q==\"},{\"type\":\"function_call\",\"id\":\"fc_0ad67c31d9ddad95016a869efd126887d1b7e2f17f155cb5dc\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0ad67c31d9ddad95016a869efbd02487d1a51eed850e6f87f5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqhp79U9UPKmTdmo9tmdil0C2KXpkFqUc4MNkYHT53Lzos9omncFPg76QzUmmSOdBcajisWBEo-xiTCvhp135uACUq8TJcdw4DluieYq6dWszijy28PFFfeO-6MmHwi7zeln1Z202zErJUEyuf1bML68VAeam5PqlMLG-a4-pmnWiH2ExWKibTUX37QoMQoArrkccJOCmxwDflV_kWDPMFxQVDfeMg9fd1gVv2u-x1Mjk0b9mJDOq0Fe5Gh-IkpWzfXgZTdptFmCM75cksvs61Rqsx6P33czal-LSixEF0WMizCvbMQmqKGs7MKGMeoa6j6vWOnB3ICIbv6FShnSaTpZWJFwejvOurkfuxa-2q6xVDZsBoQCgMWPHsqLxwAo1JKdfBk0pMvSuvpw2BRxykUZ1ULCYJ-BypST65292-EuSZFIuXPMPir-_raSCTsgsZNMscDG6ll3qksDTDS6_o5NutD7Ra-WZzaUe_HQlSLKLACTc4qv2EK1QoC4aYv4goxkTSx17WhS2D86lILgkUd-TIHjJ6iR3uxSNx7YeBNxiJgddIAEjAaSrdF-WDouSNT9k3efd5HhT3zahIOMKgb3XIQzFOYWfWgea5-SbaIdKwne9hU0QyhcBQs6yoifSg-fJZtahbPb-GCDYnOLlH-bV94vldoccb-2P1JdB3jaLj5tJUecfr2H4qiu8MgkPj0TkwYNbJynYmJo9H5Lm-XJ9gfzIXzJh0arKwsS4gwDLf4J3LOEF3WEW3mknOjjb9PrLmHRYXQQh9tTiX9ILPZpbufkyCurTUMQgWiSCitXBC6FoLXRHilSmb-6_avBnlUMziMfey-FkKvRfiPox6BaJrnOq6SGlOv11y7EKvzrn29la7HKPygYenDAkyq2mq0Zk2nLWNmJcv9sQTBrkBdFMmJYPi2J2im8XD5MmAjEL8R4FCBHoPIIZ6pENQykvH8PhpWKuzF5gJlY3Vwz4iJ0Qb9TrNI0hzBoI1U0LeB5FJ2HgjZQwCFF5x3ubh72xrUsFpuyYyYPa8GDT0Bo-LW_IlJ_mN4EwI5Nk9n-8Bt015yxsfpa5YaDeCeQFcdj8SD0UAd7QWtGACpzKcIj1-vJJU7OiwscV_v1dLvoiEe1ehI9jcvPn28TgHlo_dippe0iMN4FAm1Bf8vtWVMFDvfV1rPv1pAFFnSa9XqFszD5Exo_xzcQEoKXvQv3OnUtoiM4Db4uadClazLjoep2TQgHcJBVbTbLySTVPmok4ROFQZsU_mq4vu2M__d8HOjADfIIYz5VLVQKNpo0Hv_QkT2bn56Q==\"},{\"type\":\"function_call\",\"id\":\"fc_0ad67c31d9ddad95016a869efd126887d1b7e2f17f155cb5dc\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}"
},
"response": {
"status": 200,
@@ -21,7 +21,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true,\"instructions\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}"
},
"response": {
"status": 200,
@@ -21,7 +21,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true,\"instructions\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}"
},
"response": {
"status": 200,
+21
View File
@@ -26,6 +26,9 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
import("@opencode-ai/ai/providers/togetherai"),
import("@opencode-ai/ai/providers/cerebras"),
import("@opencode-ai/ai/providers/deepinfra"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -35,6 +38,24 @@ describe("provider package entrypoints", () => {
expect(modules[19].model).toBe(modules[20].model)
})
test("maps DeepInfra package settings onto its native executable model", async () => {
const DeepInfra = await import("@opencode-ai/ai/providers/deepinfra")
const settings = {
apiKey: "fixture",
baseURL: "https://provider.example.test/v1/",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" as const },
}
const deepinfra = DeepInfra.model("google/gemma-3-27b-it", settings)
expect(deepinfra.route.id).toBe("deepinfra-chat")
expect(deepinfra.route.endpoint.baseURL).toBe("https://provider.example.test/v1/openai")
expect(deepinfra.route.defaults.providerOptions).toEqual(settings.providerOptions)
expect(deepinfra.route.defaults.headers).toEqual(settings.headers)
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
})
test("maps OpenRouter and xAI package settings onto executable models", async () => {
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
const XAI = await import("@opencode-ai/ai/providers/xai")
@@ -5,6 +5,7 @@ import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinit
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
import { GoogleVertexMessages } from "../../src/providers.js"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
@@ -27,6 +28,12 @@ const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
}),
)
const vertexOpus48 = GoogleVertexMessages.configure({
accessToken: "test",
location: "global",
project: "test",
}).model("claude-opus-4-8")
const request = LLM.request({
id: "req_1",
model,
@@ -286,6 +293,149 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("keeps a terminal Vertex system update in the tool-result turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
])
}),
)
it.effect("preserves folded tool-result system updates across multi-turn Vertex history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
Message.assistant("Acknowledged."),
Message.user("Next step."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
{ role: "assistant", content: [{ type: "text", text: "Acknowledged." }] },
{ role: "user", content: [{ type: "text", text: "Next step." }] },
])
}),
)
it.effect("keeps a terminal direct Anthropic system update native", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
],
},
{
role: "system",
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
},
])
}),
)
it.effect("keeps an ordinary terminal Vertex system update native", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [Message.user("Before."), Message.system("Operator update.")],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Before." }] },
{
role: "system",
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
},
])
}),
)
it.effect("rejects a system update between a local tool call and its result", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -1,11 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src/index.js"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
import { recordedTests } from "../recorded-test.js"
@@ -59,6 +59,27 @@ describe("Amazon Bedrock Mantle provider", () => {
}),
)
it.effect("keeps Responses on HTTP when a WebSocket executor is available", () =>
Effect.gen(function* () {
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" }), {
webSocket: { execute: () => Effect.die("unexpected Mantle WebSocket request") },
}).pipe(
Effect.provide(
dynamicResponse((input) => {
expect(input.request.url).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/responses")
return Effect.succeed(
input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
}),
)
}),
),
)
}),
)
it.effect("supports bearer authentication and custom base URLs", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
@@ -83,6 +104,39 @@ describe("Amazon Bedrock Mantle provider", () => {
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
}),
)
it.effect("replays reasoning with Mantle's message-prefixed item ids", () =>
Effect.gen(function* () {
const model = AmazonBedrockMantle.configure({ apiKey: "test-key" }).responses("openai.gpt-oss-120b")
const item = { type: "reasoning", id: "msg_95d4d0af4350432a", encrypted_content: "mantle-state" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item },
{ type: "response.reasoning_summary_text.delta", item_id: item.id, delta: "Considering." },
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message, Message.user("Continue.")] }),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "msg_95d4d0af4350432a",
summary: [{ type: "summary_text", text: "Considering." }],
encrypted_content: "mantle-state",
},
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
})
const recorded = recordedTests({
@@ -1,5 +1,6 @@
import * as Anthropic from "../../src/providers/anthropic.js"
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
import * as Google from "../../src/providers/google.js"
import * as OpenAI from "../../src/providers/openai.js"
@@ -47,14 +48,16 @@ const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b
const deepseek = OpenAICompatible.deepseek
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
.model("deepseek-chat")
const together = OpenAICompatible.togetherai
.configure({
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
})
.model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const together = TogetherAI.configure({
apiKey: process.env.TOGETHER_API_KEY ?? process.env.TOGETHER_AI_API_KEY ?? "fixture",
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "fixture" }).model("gpt-oss-120b")
const groq = OpenAICompatible.groq
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
.model("llama-3.3-70b-versatile")
const deepInfra = DeepInfra.configure({ apiKey: process.env.DEEPINFRA_API_KEY ?? "fixture" }).model(
"meta-llama/Llama-3.3-70B-Instruct-Turbo",
)
const openRouter = OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" })
const openrouter = openRouter.model("openai/gpt-4o-mini")
const openrouterGpt55 = openRouter.model("openai/gpt-5.5")
@@ -193,8 +196,27 @@ describeRecordedGoldenScenarios([
name: "TogetherAI Llama 3.3 70B",
prefix: "openai-compatible-chat",
model: together,
requires: ["TOGETHER_AI_API_KEY"],
scenarios: ["text", "tool-call"],
requires: ["TOGETHER_API_KEY"],
scenarios: [
{
id: "text",
cassette: "openai-compatible-chat/togetherai-streams-text",
prompt: "Reply with exactly: Hello!",
maxTokens: 20,
},
{ id: "tool-call", cassette: "openai-compatible-chat/togetherai-streams-tool-call" },
],
},
{
name: "Cerebras GPT OSS 120B",
prefix: "cerebras-chat",
model: cerebras,
requires: ["CEREBRAS_API_KEY"],
scenarios: [
{ id: "text", maxTokens: 256, temperature: false },
{ id: "tool-call", maxTokens: 512, temperature: false },
{ id: "tool-loop", maxTokens: 512, temperature: false, timeout: 30_000 },
],
},
{
name: "Groq Llama 3.3 70B",
@@ -203,6 +225,13 @@ describeRecordedGoldenScenarios([
requires: ["GROQ_API_KEY"],
scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }],
},
{
name: "DeepInfra Llama 3.3 70B",
prefix: "deepinfra-chat",
model: deepInfra,
requires: ["DEEPINFRA_API_KEY"],
scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }],
},
{
name: "OpenRouter gpt-4o-mini",
prefix: "openai-compatible-chat",
@@ -0,0 +1,184 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
}),
)
it.effect("preserves native DeepInfra provider and route identity", () =>
Effect.gen(function* () {
const deepinfra = DeepInfra.configure({ apiKey: "fixture" }).model("google/gemma-3-27b-it")
expect(deepinfra).toMatchObject({
provider: "deepinfra",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
route: { id: "deepinfra-chat", protocol: "openai-chat" },
})
expect(deepinfra.route.endpoint.baseURL).toBe("https://api.deepinfra.com/v1/openai")
}),
)
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
Effect.gen(function* () {
const together = yield* compileRequest(
LLM.request({
model: TogetherAI.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("llama"),
prompt: "Use a tool.",
generation: { maxTokens: 32 },
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
],
providerOptions: { store: true },
}),
)
expect(together.body).toMatchObject({
max_tokens: 32,
stream_options: { include_usage: true },
tools: [{ function: { name: "lookup" } }],
})
expect(together.body).not.toHaveProperty("max_completion_tokens")
expect(together.body).not.toHaveProperty("store")
expect(together.body.tools?.[0]?.function).not.toHaveProperty("strict")
const cerebras = yield* compileRequest(
LLM.request({
model: Cerebras.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("qwen"),
generation: { maxTokens: 48 },
messages: [
Message.user("Think first."),
Message.assistant([
{ type: "reasoning", text: "A deliberate thought." },
{ type: "text", text: "An answer." },
]),
Message.user("Continue."),
],
providerOptions: { store: true },
}),
)
expect(cerebras.body).toMatchObject({
max_tokens: 48,
messages: [
{ role: "user", content: "Think first." },
{ role: "assistant", content: "An answer.", reasoning: "A deliberate thought." },
{ role: "user", content: "Continue." },
],
})
expect(cerebras.body).not.toHaveProperty("max_completion_tokens")
expect(cerebras.body).not.toHaveProperty("store")
expect(cerebras.body.messages[1]).not.toHaveProperty("reasoning_content")
}),
)
it.effect("normalizes DeepInfra API roots without duplicating the OpenAI path", () =>
Effect.gen(function* () {
for (const baseURL of [
"https://gateway.example/v1",
"https://gateway.example/v1/",
"https://gateway.example/v1/openai",
"https://gateway.example/v1/openai/",
]) {
expect(DeepInfra.configure({ apiKey: "fixture", baseURL }).model("gemma").route.endpoint.baseURL).toBe(
"https://gateway.example/v1/openai",
)
}
}),
)
it.effect("maps package settings onto native executable models", () =>
Effect.gen(function* () {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
}),
)
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
const scenarios = [
{
model: TogetherAI.configure().model("llama"),
env: { TOGETHER_API_KEY: "together-primary", TOGETHER_AI_API_KEY: "together-legacy" },
token: "together-primary",
url: "https://api.together.xyz/v1/chat/completions",
},
{
model: TogetherAI.configure().model("llama"),
env: { TOGETHER_AI_API_KEY: "together-legacy" },
token: "together-legacy",
url: "https://api.together.xyz/v1/chat/completions",
},
{
model: Cerebras.configure().model("qwen"),
env: { CEREBRAS_API_KEY: "cerebras-secret" },
token: "cerebras-secret",
url: "https://api.cerebras.ai/v1/chat/completions",
},
{
model: DeepInfra.configure().model("gemma"),
env: { DEEPINFRA_API_KEY: "deepinfra-secret" },
token: "deepinfra-secret",
url: "https://api.deepinfra.com/v1/openai/chat/completions",
},
]
yield* Effect.forEach(scenarios, (scenario) =>
LLM.generate(LLM.request({ model: scenario.model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(scenario.url)
expect(request.headers.get("authorization")).toBe(`Bearer ${scenario.token}`)
return input.respond(
sseEvents(
{ id: "chatcmpl_fixture", choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
{ id: "chatcmpl_fixture", choices: [{ delta: {}, finish_reason: "stop" }] },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: scenario.env }))),
Effect.tap((response) => Effect.sync(() => expect(response.text).toBe("Hello"))),
),
)
}),
)
})
@@ -85,6 +85,28 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits empty and whitespace-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.assistant([]),
Message.assistant(""),
Message.assistant(" \n\t "),
Message.assistant("After."),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before." },
{ role: "assistant", content: "After." },
])
}),
)
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -147,6 +169,56 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves observed reasoning fields when reasoning is required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireReasoning: true } }),
messages: [
Message.assistant([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { reasoningField: "reasoning_text" } },
},
{ type: "text", text: "Hello" },
]),
Message.assistant("Done"),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning_text: "thinking" },
{ role: "assistant", content: "Done", reasoning_content: "" },
])
}),
)
it.effect("omits empty configured reasoning fields when reasoning is explicitly optional", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, {
compatibility: { reasoningField: "reasoning_text", requireReasoning: false },
}),
messages: [
Message.assistant([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
]),
Message.assistant("Done"),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning_text: "thinking" },
{ role: "assistant", content: "Done" },
])
}),
)
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -366,6 +438,35 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
Effect.gen(function* () {
const id = `call_${"a".repeat(48)}`
const models = [
model,
Azure.configure({ baseURL: "https://opencode-test.openai.azure.com/openai/", apiKey: "test" }).chat("gpt-4o"),
]
yield* Effect.forEach(models, (selected) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "Sunny" }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: id.slice(0, 40) }] },
{ role: "tool", tool_call_id: id.slice(0, 40) },
])
}),
)
}),
)
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
@@ -431,6 +532,30 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("bridges image tool results before their synthetic user message when required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
},
}),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -238,6 +238,135 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("normalizes tool call IDs for the selected model family", () =>
Effect.gen(function* () {
const longID = `call_${"a".repeat(48)}`
const cases = [
{ provider: "custom", model: "mistral-small", id: "toolu_01CBhTTz95qkd9LJMdC9sf8t", expected: "toolu01CB" },
{ provider: "custom", model: "devstral-small", id: "abc", expected: "abc000000" },
{ provider: "custom", model: "codestral-latest", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "pixtral-large", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "open-mixtral-8x22b", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "gateway", model: "anthropic/claude-sonnet-4", id: "call|item/+", expected: "call_item__" },
{ provider: "gateway", model: "openai/gpt-4o", id: longID, expected: longID.slice(0, 40) },
{ provider: "custom", model: "ordinary-model", id: "call|item/+", expected: "call|item/+" },
{ provider: "mistral", model: "zai-glm-5-2", id: "call_long_identifier", expected: "call_long_identifier" },
]
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatibleChat.route
.with({ provider: item.provider, endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: item.model }),
messages: [
Message.assistant([ToolCallPart.make({ id: item.id, name: "lookup", input: {} })]),
Message.tool({ id: item.id, name: "lookup", result: { type: "content", value: [] } }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: item.expected }] },
{ role: "tool", tool_call_id: item.expected },
])
}),
)
}),
)
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
Effect.gen(function* () {
const cases = [
{ id: "mistral-small", bridge: true },
{ id: "devstral-small", bridge: true },
{ id: "codestral-latest", bridge: true },
{ id: "pixtral-large", bridge: true },
{ id: "open-mixtral-8x22b", bridge: true },
{ id: "ordinary-model", bridge: false },
{ id: "ordinary-model", override: true, bridge: true },
{ id: "mistral-small", override: false, bridge: false },
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({
id: item.id,
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
})
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
Message.user("What next?"),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
)
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
}),
)
it.effect("requires reasoning for DeepSeek models, providers, and endpoints unless explicitly overridden", () =>
Effect.gen(function* () {
const cases = [
{ id: "DeepSeek-V3", provider: "custom", baseURL: "https://api.custom.test/v1", required: true },
{ id: "custom-model", provider: "deepseek", baseURL: "https://api.custom.test/v1", required: true },
{ id: "custom-model", provider: "custom", baseURL: "https://API.DeepSeek.COM/v1", required: true },
{ id: "ordinary-model", provider: "custom", baseURL: "https://api.custom.test/v1", required: false },
{
id: "ordinary-model",
provider: "custom",
baseURL: "https://api.custom.test/v1",
compatibility: { requireReasoning: true, reasoningField: "reasoning" },
required: true,
field: "reasoning",
},
{
id: "deepseek-chat",
provider: "deepseek",
baseURL: "https://api.deepseek.com/v1",
compatibility: { requireReasoning: false },
required: false,
},
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: item.provider, endpoint: { baseURL: item.baseURL } })
.model({ id: item.id, compatibility: "compatibility" in item ? item.compatibility : undefined })
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant("Hello"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
],
}),
)
const field = "field" in item ? item.field : "reasoning_content"
for (const message of prepared.body.messages.filter((message) => message.role === "assistant")) {
if (item.required) expect(message).toHaveProperty(field, "")
else expect(message).not.toHaveProperty(field)
}
}),
)
}),
)
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -47,10 +47,8 @@ describe("Open Responses-compatible route", () => {
})
expect(prepared.body).toEqual({
model: "example-model",
input: [
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
instructions: "You are concise.",
stream: true,
store: false,
include: ["reasoning.encrypted_content"],
@@ -84,10 +82,12 @@ describe("Open Responses-compatible route", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
system: "Initial instructions.",
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.instructions).toBe("Initial instructions.")
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
@@ -195,15 +195,19 @@ describe("Open Responses-compatible route", () => {
model,
messages: [
Message.assistant([
// The baseline does not enforce a provider id grammar, so a
// non-OpenAI but well-formed token is resent as-is.
{ type: "text", text: "Kept.", providerMetadata: { openresponses: { itemId: "history_1" } } },
// Shape violations are dropped even without a grammar policy.
{
type: "text",
text: "Dropped.",
providerMetadata: { openresponses: { itemId: `m${"a".repeat(64)}` } },
text: "Long.",
providerMetadata: { openresponses: { itemId: `history_${"a".repeat(64)}` } },
},
{
type: "text",
text: "Opaque.",
providerMetadata: { openresponses: { itemId: "provider_value/with+symbols" } },
},
{ type: "text", text: "No suffix.", providerMetadata: { openresponses: { itemId: "msg_" } } },
{ type: "text", text: "No prefix.", providerMetadata: { openresponses: { itemId: "_item" } } },
]),
],
}),
@@ -218,13 +222,128 @@ describe("Open Responses-compatible route", () => {
},
{
type: "message",
id: `history_${"a".repeat(64)}`,
role: "assistant",
content: [{ type: "output_text", text: "Dropped." }],
content: [{ type: "output_text", text: "Long." }],
},
{
type: "message",
id: "provider_value/with+symbols",
role: "assistant",
content: [{ type: "output_text", text: "Opaque." }],
},
{
type: "message",
role: "assistant",
content: [
{ type: "output_text", text: "No suffix." },
{ type: "output_text", text: "No prefix." },
],
},
])
}),
)
it.effect("replays only shared hosted tool items", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const items = [
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ type: "x_search_call", id: "x_search_1", status: "completed" },
{ type: "future_call", id: "future_1", status: "completed" },
{ type: "file_search_call", id: "fs_1", queries: "not-an-array" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openresponses: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[1]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[3]) }] },
])
}),
)
it.effect("routes response deltas by output index", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
}),
)
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -136,6 +136,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
instructions: "Call get_weather once, then reply exactly: Paris is sunny.",
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
@@ -167,8 +168,8 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
@@ -204,8 +205,8 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
@@ -112,10 +112,8 @@ describe("OpenAI Responses route", () => {
expect(prepared.body).toEqual({
model: "gpt-4.1-mini",
input: [
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
instructions: "You are concise.",
store: false,
include: ["reasoning.encrypted_content"],
stream: true,
@@ -469,7 +467,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a tool call with only the new tool output", () =>
it.effect("continues an item-id-less tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,7 +483,6 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -830,7 +827,9 @@ describe("OpenAI Responses route", () => {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
yield* exchange.driver.create(undefined).pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
yield* exchange.driver
.create(undefined)
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
@@ -1595,8 +1594,8 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body).toMatchObject({
instructions: "You are concise. Continue from the provided history.",
input: [
{ role: "system", content: "You are concise. Continue from the provided history." },
{
role: "user",
content: [
@@ -2051,6 +2050,204 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes assistant text by output index when its item id disagrees", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Indexed")
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
)
it.effect("routes interleaved function calls by output index", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "first", arguments: "" }
const second = { type: "function_call", id: "fc_2", call_id: "call_2", name: "second", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: first },
{ type: "response.output_item.added", output_index: 3, item: second },
{ type: "response.function_call_arguments.delta", output_index: 1, item_id: "fc_2", delta: '{"a":' },
{ type: "response.function_call_arguments.delta", output_index: 3, item_id: "fc_1", delta: '{"b":' },
{
type: "response.function_call_arguments.done",
output_index: 3,
item_id: "fc_1",
arguments: '{"b":2}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_2",
arguments: '{"a":1}',
},
{ type: "response.output_item.done", output_index: 1, item: { ...first, arguments: '{"a":1}' } },
{ type: "response.output_item.done", output_index: 3, item: { ...second, arguments: '{"b":2}' } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"a":' },
{ id: "call_2", text: '{"b":' },
{ id: "call_2", text: "2}" },
{ id: "call_1", text: "1}" },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "first", input: { a: 1 } }),
expect.objectContaining({ id: "call_2", name: "second", input: { b: 2 } }),
])
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 4,
item: { type: "reasoning", id: "rs_1" },
},
{
type: "response.reasoning_summary_part.added",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
},
{
type: "response.reasoning_summary_text.delta",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Thinking",
},
{
type: "response.reasoning_summary_part.done",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
},
{
type: "response.output_item.done",
output_index: 4,
item: { type: "reasoning", id: "rs_1", encrypted_content: "state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Thinking")
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("routes native reasoning text deltas by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "wrong_reasoning", delta: "Raw" },
{ type: "response.output_item.done", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw")
}),
)
it.effect("falls back to item ids when an output index was not registered", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 9, item_id: "msg_1", delta: "Fallback" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Fallback")
}),
)
it.effect("rejects output text events without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -2070,6 +2267,25 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("requires item ids even when their output index is known", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 0, delta: "Missing item ID" },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.output_text.delta is missing item_id")
}),
)
it.effect("ignores deltas without a matching output item", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2120,13 +2336,25 @@ describe("OpenAI Responses route", () => {
it.effect("rejects function argument events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
{ type: "response.function_call_arguments.delta", delta: "{}" },
{ type: "response.function_call_arguments.done", arguments: "{}" },
{ type: "response.function_call_arguments.delta", output_index: 0, delta: "{}" },
{ type: "response.function_call_arguments.done", output_index: 0, arguments: "{}" },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))),
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.flip,
)
@@ -2580,7 +2808,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
it.effect("preserves final reasoning metadata when storage is enabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
Effect.provide(
@@ -2598,7 +2826,7 @@ describe("OpenAI Responses route", () => {
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -2608,7 +2836,11 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
}),
)
@@ -2713,7 +2945,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("references stored reasoning items by id", () =>
it.effect("replays complete reasoning items when storage is enabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -2723,7 +2955,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "Checked the previous diff.",
providerMetadata: { openai: { itemId: "rs_1" } },
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
]),
],
@@ -2731,12 +2963,20 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
it.effect("references stored provider-executed hosted tool results by id", () =>
it.effect("replays complete hosted tool items when storage is enabled", () =>
Effect.gen(function* () {
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -2753,7 +2993,7 @@ describe("OpenAI Responses route", () => {
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
@@ -2765,14 +3005,15 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
{ type: "item_reference", id: "ws_1" },
item,
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("continues stateless hosted tool results with their text form", () =>
it.effect("replays stateless hosted tool results as native provider items", () =>
Effect.gen(function* () {
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -2790,7 +3031,7 @@ describe("OpenAI Responses route", () => {
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
@@ -2803,6 +3044,74 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("replays OpenAI hosted tool extensions but rejects foreign and unknown items", () =>
Effect.gen(function* () {
const items = [
{ type: "computer_call", id: "computer_1", status: "completed", action: { type: "click", x: 1, y: 2 } },
{ type: "x_search_call", id: "x_search_1", status: "completed" },
{ type: "future_call", id: "future_1", status: "completed" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[1]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
])
}),
)
it.effect("preserves foreign hosted tool results as portable message content when storage is enabled", () =>
Effect.gen(function* () {
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
Message.assistant([
ToolCallPart.make({
id: "ws_1",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
}),
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
Message.user("Continue."),
],
providerOptions: { store: true },
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }],
@@ -2812,38 +3121,79 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("drops replayed item ids outside the server's grammar", () =>
it.effect("does not replay hosted tool items whose result id differs from provider metadata", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_other", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_other","status":"completed"}' }],
},
])
}),
)
it.effect("preserves provider-issued item ids and removes malformed ids without dropping items", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
// Fails the message id prefix.
{
type: "text",
text: "Hello",
providerMetadata: { openai: { itemId: "history_1" } },
},
// Oversized for the Responses item id limit.
{
type: "text",
text: "World",
providerMetadata: { openai: { itemId: `m${"a".repeat(64)}` } },
providerMetadata: { openai: { itemId: `message_${"a".repeat(64)}` } },
},
// Fails the reasoning id prefix, so the whole item is unreplayable
// statelessly and is skipped rather than sent malformed.
{
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "thinking_1", reasoningEncryptedContent: "encrypted-state" } },
},
{
type: "reasoning",
text: "Missing suffix.",
providerMetadata: { openai: { itemId: "rs_", reasoningEncryptedContent: "another-state" } },
},
{
type: "reasoning",
text: "No prefix separator.",
providerMetadata: { openai: { itemId: "550e8400-e29b-41d4-a716-446655440000" } },
},
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: { openai: { itemId: "toolu_01A" } },
}),
ToolCallPart.make({
id: "call_2",
name: "lookup",
input: { query: "news" },
providerMetadata: { openai: { itemId: "fc_" } },
}),
]),
],
}),
@@ -2852,41 +3202,70 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{
type: "message",
id: "history_1",
role: "assistant",
content: [
{ type: "output_text", text: "Hello" },
{ type: "output_text", text: "World" },
],
content: [{ type: "output_text", text: "Hello" }],
},
{
type: "message",
id: `message_${"a".repeat(64)}`,
role: "assistant",
content: [{ type: "output_text", text: "World" }],
},
{
type: "reasoning",
id: "thinking_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
{
type: "reasoning",
summary: [{ type: "summary_text", text: "Missing suffix." }],
encrypted_content: "another-state",
},
{
type: "reasoning",
summary: [{ type: "summary_text", text: "No prefix separator." }],
},
{
type: "function_call",
id: "toolu_01A",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
{
type: "function_call",
call_id: "call_2",
name: "lookup",
arguments: '{"query":"news"}',
},
])
}),
)
it.effect("keeps well-formed hosted references and drops malformed ones under storage", () =>
it.effect("falls back to portable hosted results when stored item metadata is malformed", () =>
Effect.gen(function* () {
const hostedResult = (itemId: string) => [
ToolCallPart.make({
id: itemId,
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId } },
}),
{
type: "tool-result" as const,
id: itemId,
name: "web_search",
result: { type: "json" as const, value: { status: "completed" } },
providerExecuted: true as const,
providerMetadata: { openai: { itemId } },
},
]
const hostedResult = (itemId: string) => {
const item = { type: "web_search_call", id: itemId, status: "completed" }
return [
ToolCallPart.make({
id: itemId,
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId } },
}),
{
type: "tool-result" as const,
id: itemId,
name: "web_search",
result: { type: "json" as const, value: item },
providerExecuted: true as const,
providerMetadata: { openai: { itemId } },
},
]
}
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -2895,7 +3274,13 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }])
expect(prepared.body.input).toEqual([
{ type: "web_search_call", id: "ws_1", status: "completed" },
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"bad ref","status":"completed"}' }],
},
])
}),
)
@@ -2941,6 +3326,43 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves foreign hosted images as portable image content when storage is enabled", () =>
Effect.gen(function* () {
const item = { type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" }
const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
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" } },
}),
]),
],
providerOptions: { store: true },
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
])
}),
)
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -2979,7 +3401,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
it.effect("replays stateless reasoning without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -3009,6 +3431,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.body).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: null,
},
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
@@ -3117,6 +3545,43 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3341,6 +3806,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3644,13 +4140,15 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("decodes image generation output as image content", () =>
it.effect("replays hosted image results as portable content regardless of storage", () =>
Effect.gen(function* () {
const item = {
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
action: "generate",
output_format: "png",
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -3663,6 +4161,9 @@ describe("OpenAI Responses route", () => {
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
providerMetadata: { openai: { itemId: "ig_1" } },
})
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
@@ -3671,7 +4172,52 @@ describe("OpenAI Responses route", () => {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
providerMetadata: { openai: { itemId: "ig_1" } },
})
const prepared = yield* Effect.forEach([false, true], (store) =>
compileRequest(LLM.request({ model, messages: [response.message], providerOptions: { store } })),
)
expect(prepared.map((request) => request.body.input)).toEqual([
[{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
[{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
])
}),
)
it.effect("preserves failed hosted tool results as portable error content", () =>
Effect.gen(function* () {
const item = {
type: "web_search_call",
id: "ws_failed",
status: "failed",
error: { code: "search_failed", message: "Search unavailable" },
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
result: { type: "error", value: item.error },
providerMetadata: { openai: { itemId: "ws_failed" } },
})
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message], providerOptions: { store: true } }),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: '{"code":"search_failed","message":"Search unavailable"}' }],
},
])
}),
)
+102 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent } from "../../src/index.js"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
@@ -14,9 +14,9 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
describe("xAI Responses route", () => {
it.effect("extends the Open Responses baseline directly", () =>
it.effect("composes the Open Responses baseline with xAI extensions", () =>
Effect.gen(function* () {
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
@@ -70,16 +70,106 @@ describe("xAI Responses route", () => {
}),
)
it.effect("parses xAI hosted tool items", () =>
it.effect("routes xAI reasoning summaries by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
type: "response.output_item.added",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1" },
},
{
type: "response.reasoning_summary_text.delta",
output_index: 3,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.reasoning).toBe("Considering.")
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("replays xAI hosted tool items when continuing with the same provider", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "tool-result",
id: "x_search_1",
name: "x_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([item])
}),
)
it.effect("replays shared and xAI hosted tool items but rejects OpenAI extensions", () =>
Effect.gen(function* () {
const items = [
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" },
{ type: "computer_call", id: "computer_1", status: "completed" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
items[1],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
])
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { id: "response_1" } },
),
),
@@ -91,6 +181,11 @@ describe("xAI Responses route", () => {
name: "x_search",
input: { query: "news" },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
})
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
result: { type: "json", value: item },
providerMetadata: { xai: { itemId: "x_search_1" } },
})
}),
)
+2
View File
@@ -20,6 +20,7 @@ type ScenarioInput =
readonly name?: string
readonly cassette?: string
readonly tags?: ReadonlyArray<string>
readonly prompt?: string
readonly maxTokens?: number
readonly temperature?: number | false
readonly timeout?: number
@@ -87,6 +88,7 @@ const runTarget = (target: TargetInput) => {
yield* runGoldenScenario(input.id, {
id: `recorded_${kebab(target.name).replaceAll("-", "_")}_${input.id.replaceAll("-", "_")}`,
model: target.model,
prompt: input.prompt,
maxTokens: input.maxTokens,
temperature: input.temperature,
})
+2 -1
View File
@@ -164,6 +164,7 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
export interface GoldenScenarioContext {
readonly id: string
readonly model: LanguageModel
readonly prompt?: string
readonly maxTokens?: number
readonly temperature?: number | false
}
@@ -298,7 +299,7 @@ const runGeneratedConversation = (context: GoldenScenarioContext, steps: Readonl
const runTextScenario = (context: GoldenScenarioContext) =>
runGeneratedConversation(context, [
user("Reply exactly with: Hello!"),
user(context.prompt ?? "Reply exactly with: Hello!"),
assistant.expectText(/^Hello!?$/, {
system: "You are concise.",
maxTokens: context.maxTokens ?? 40,
+32
View File
@@ -102,6 +102,38 @@ describe("AI.Usage", () => {
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
test("sseFraming ignores retry directives without ending the stream", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
encoder.encode("retry: 2000\n\n"),
encoder.encode('data: {"second":true}\n\n'),
).pipe(Stream.rechunk(1)),
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
})
test("sseFraming preserves event data around retry directives", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
encoder.encode("data: second\n\n"),
).pipe(Stream.rechunk(1)),
new Set(["update"]),
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(["first\nsecond"])
})
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
+1 -1
View File
@@ -500,7 +500,7 @@ export const dict = {
"context.stats.lastActivity": "Last Activity",
"context.usage.tokens": "Tokens",
"context.usage.usage": "Usage",
"context.usage.usage": "Context Usage",
"context.usage.cost": "Cost",
"context.usage.clickToView": "Click to view context",
"context.usage.view": "View context usage",
@@ -92,6 +92,15 @@ export function createTimelineVirtualizer(input: Input) {
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
// Do not replace this with TanStack's default measurer: without a ResizeObserver entry,
// it returns the cached height instead of reading the element (TanStack/virtual#1183).
// Restored sessions, deferred tools, and rewrapped content can then keep stale heights;
// our fixed-height, overflow-clipped rows will hide their content. Keep observer entries
// on the cheap precomputed path, but make explicit measurements read the real height.
measureElement: (element, entry) => {
const box = entry?.borderBoxSize[0]
return box ? Math.round(box.blockSize) : element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
@@ -11,7 +11,8 @@ import { useLanguage } from "@/runtime/i18n/language"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
import { LocationProvider } from "@/workspaces/location"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { createEditProjectModel } from "./project-model"
import { ProjectSettingsExtensions } from "./project-extensions"
import { SettingsServerDataScope } from "@/settings/server-scope"
@@ -57,11 +58,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Tabs.List>
<div class="project-settings-nav">
<Tabs.Trigger value="general">
<ProjectAvatar
fallback={projectName()}
variant={getProjectAvatarVariant(props.project.icon?.color)}
class="!size-4 shrink-0"
/>
<ProjectIcon project={props.project} class="!size-4 shrink-0" />
<span class="truncate">{projectName()}</span>
</Tabs.Trigger>
<Tabs.Trigger value="scripts">
@@ -114,14 +111,14 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
onDragLeave={model.dragLeave}
onClick={model.iconClick}
>
<ProjectAvatar
<ProjectIcon
project={props.project}
fallback={model.store.name || model.defaultName()}
src={getProjectAvatarSource(props.project.id, {
icon={{
color: model.store.color,
url: props.project.icon?.url,
override: model.store.iconOverride,
})}
variant={getProjectAvatarVariant(model.store.color)}
}}
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
/>
<span
@@ -1,13 +1,12 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/runtime/i18n/language"
import { useGlobal } from "@/runtime/server/runtime"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { ServerConnection, serverName } from "@/runtime/server/registry"
import { displayName } from "@/shell/layout/helpers"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { InlineServerSelect } from "@/settings/server-select"
import { DialogEditProject } from "./project-dialog"
import "@/settings/settings.css"
@@ -40,14 +39,13 @@ export const SettingsProjects: Component = () => {
const ProjectRow: Component<{ project: ProjectItem; server: ServerConnection.Any }> = (props) => {
const name = () => displayName(props.project)
const color = () => getProjectAvatarVariant(props.project.icon?.color)
return (
<div
class="group flex items-center justify-between gap-5 px-4 py-2.5 rounded-lg bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)] cursor-pointer transition-all hover:bg-v2-background-bg-layer-01"
onClick={() => openProjectSettings(props.project, props.server)}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<ProjectAvatar fallback={name()} variant={color()} class="shrink-0" />
<ProjectIcon project={props.project} class="shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{name()}</span>
</div>
<div class="flex items-center gap-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
@@ -0,0 +1,24 @@
import { ProjectAvatar, type ProjectAvatarProps } from "@opencode-ai/ui/project-avatar"
import { splitProps } from "solid-js"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
type ProjectIconProps = Omit<ProjectAvatarProps, "fallback" | "src" | "variant"> & {
project: LocalProject
fallback?: string
icon?: LocalProject["icon"]
}
export function ProjectIcon(props: ProjectIconProps) {
const [local, rest] = splitProps(props, ["project", "fallback", "icon"])
const icon = () => local.icon ?? local.project.icon
return (
<ProjectAvatar
{...rest}
fallback={local.fallback ?? displayName(local.project)}
src={getProjectAvatarSource(local.project.id, icon())}
variant={getProjectAvatarVariant(icon()?.color)}
/>
)
}
@@ -6,6 +6,7 @@ import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
export function SessionUIProvider(
@@ -17,6 +18,7 @@ export function SessionUIProvider(
const navigate = useNavigate()
const params = useParams()
const data = useData()
const serverSDK = useServerSDK()
const tabs = useTabs()
const directory = () => props.directory
const href = (sessionID: string) => sessionHref(props.server, sessionID)
@@ -53,6 +55,7 @@ export function SessionUIProvider(
data={sessionUIData()}
directory={directory()}
sessionID={params.id}
shellOutput={(input) => serverSDK.api.shell.output(input)}
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
@@ -113,6 +113,32 @@ test("reactive count updates preserve measured row sizes", () => {
})
})
test("explicit measurement refreshes a cached row size with a custom measurer", () => {
const root = document.createElement("div")
const element = document.createElement("div")
element.dataset.index = "0"
Object.defineProperty(element, "offsetHeight", { value: 120 })
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 1,
estimateSize: () => 60,
initialRect: { width: 400, height: 200 },
getScrollElement: () => root,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
measureElement: (node) => node.offsetHeight,
})
virtualizer.getTotalSize()
virtualizer.resizeItem(0, 60)
virtualizer._willUpdate()
virtualizer.measureElement(element)
expect(virtualizer.itemSizeCache.get(0)).toBe(120)
expect(virtualizer.getTotalSize()).toBe(120)
})
test("initial rect projects rows before a scroll element connects", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
+19 -1
View File
@@ -9,6 +9,7 @@ import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -78,6 +79,23 @@ const appAssetsPlugin: BunPlugin = {
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
platform: item.os,
arch: item.arch,
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
})
const opencodePtyPlugin: BunPlugin = {
name: "opencode-pty-binary",
setup(build) {
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
loader: "js",
contents: opencodePty
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
: "export default undefined",
}))
},
}
const simulationInputs = new Set<string>()
const simulationGraphPlugin: BunPlugin = {
name: "opencode-simulation-graph",
@@ -105,7 +123,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+7
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { collectFiles } from "./files"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
@@ -18,6 +19,11 @@ export type NodeAsset = {
}
export async function collectNodeAssets(target: NodeTarget) {
const opencodePty = await resolveOpencodePty({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
})
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
@@ -41,6 +47,7 @@ export async function collectNodeAssets(target: NodeTarget) {
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
...(await collectFiles(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
+102
View File
@@ -0,0 +1,102 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const VERSION = "0.1.5"
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
const SHA256 = {
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
} as const
export type OpencodePtyAsset = {
readonly source: string
readonly version: string
readonly sha256: string
}
type Target = {
readonly platform: string
readonly arch: string
readonly libc?: "glibc" | "musl"
}
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
export function resolveOpencodePty(target: Target) {
const rustTarget = targetName(target)
if (!rustTarget) return Promise.resolve(undefined)
const existing = pending.get(rustTarget)
if (existing) return existing
const result = acquire(rustTarget).catch((error) => {
pending.delete(rustTarget)
throw error
})
pending.set(rustTarget, result)
return result
}
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
const executable = path.join(root, "opencode-pty")
const cached = await readFile(executable).catch(() => undefined)
if (cached)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(cached).digest("hex"),
}
await mkdir(root, { recursive: true })
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
const response = await fetch(`${RELEASE}/${archiveName}`)
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
const archive = new Uint8Array(await response.arrayBuffer())
const actual = createHash("sha256").update(archive).digest("hex")
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
try {
const archivePath = path.join(temporary, archiveName)
await writeFile(archivePath, archive)
run("tar", ["-xzf", archivePath, "-C", temporary])
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
const bytes = await readFile(source)
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
await rename(staged, executable).catch(async (error) => {
await rm(staged, { force: true })
if (!(await readFile(executable).catch(() => undefined))) throw error
})
const installed = await readFile(executable)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(installed).digest("hex"),
}
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function targetName(target: Target): keyof typeof SHA256 | undefined {
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
if (!arch) return undefined
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (target.platform === "linux" && target.libc === "musl")
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
return undefined
}
function run(command: string, args: readonly string[]) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
+27
View File
@@ -0,0 +1,27 @@
import { Schema } from "effect"
import { format } from "prettier"
import { Info, SchemaURL } from "../src/config/schema"
const target = process.argv[2]
if (!target) throw new Error("A schema output path is required")
const document = Schema.toJsonSchemaDocument(Info)
const content = await format(
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: SchemaURL,
...document.schema,
...(Object.keys(document.definitions).length ? { $defs: document.definitions } : {}),
}),
{ parser: "json", printWidth: 120 },
)
if (process.argv.includes("--check")) {
if ((await Bun.file(target).text()) !== content) {
console.error("Generated CLI config schema is stale. Run `bun run generate` from packages/www.")
process.exit(1)
}
process.exit(0)
}
await Bun.write(target, content)
+2 -1
View File
@@ -79,6 +79,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "List configuration sources" }),
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
],
}),
Spec.make("console", {
@@ -180,7 +181,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("add", {
description: "Install a plugin and add it to the global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
},
}),
Spec.make("remove", {
@@ -0,0 +1,17 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
export default Runtime.handler(
Commands.commands.debug.commands.paths,
Effect.fn("cli.debug.paths")(function* () {
const global = yield* Global.Service
process.stdout.write(
Object.entries(global)
.map(([key, value]) => `${key.padEnd(10)} ${value}${EOL}`)
.join(""),
)
}),
)
@@ -13,10 +13,8 @@ import { Config } from "../../../config"
export default Runtime.handler(
Commands.commands.plugin.commands.add,
Effect.fn("cli.plugin.add")(function* (input) {
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
return yield* Effect.fail(
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
)
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
+4 -2
View File
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info } from "./schema"
import { Info, SchemaURL } from "./schema"
export * from "./schema"
@@ -87,7 +87,9 @@ export const layer = Layer.effect(
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "{}"))
const text = yield* fs
.readFileString(file)
.pipe(Effect.orElseSucceed(() => JSON.stringify({ $schema: SchemaURL }, null, 2)))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
+4 -3
View File
@@ -7,7 +7,7 @@ import { Effect, FileSystem, Option, Schema } from "effect"
import { randomUUID } from "crypto"
import { applyEdits, createScanner, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import { Info } from "./schema"
import { Info, SchemaURL } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
@@ -100,8 +100,9 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
const kv = yield* readJson(path.join(input.state, "kv.json"))
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const values = migrateV1(legacy, kv ?? {})
if (!Object.keys(values).length) return
const migrated = { $schema: SchemaURL, ...values }
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
+6 -1
View File
@@ -1,5 +1,10 @@
import { Config } from "@opencode-ai/tui/config"
import { Schema } from "effect"
export const Info = Schema.Struct({ ...Config.Info.fields })
export const SchemaURL = "https://opencode.ai/v2/cli.json"
export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({ description: "JSON Schema for CLI configuration" }),
...Config.Info.fields,
})
export type Info = Schema.Schema.Type<typeof Info>
+1
View File
@@ -27,6 +27,7 @@ const Handlers = Runtime.handlers(Commands, {
debug: {
agents: () => import("./commands/handlers/debug/agents"),
config: () => import("./commands/handlers/debug/config"),
paths: () => import("./commands/handlers/debug/paths"),
},
console: {
login: () => import("./commands/handlers/console/login"),
+2
View File
@@ -13,6 +13,7 @@ export function nodeTarget(platform: string, arch: string) {
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
return {
platform: targetPlatform,
@@ -25,6 +26,7 @@ export function nodeTarget(platform: string, arch: string) {
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
fffFfiPackage,
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
opencodePtyAsset,
}
}
+70 -2
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Option } from "effect"
import { Effect, FileSystem, Option, Schema } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
@@ -17,6 +17,67 @@ function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service
)
}
test("generates reusable keybind schemas and preserves descriptions and numeric constraints", () => {
const document = Schema.toJsonSchemaDocument(Config.Info)
expect(document).toHaveProperty(["definitions", "TuiKeybind.BindingValue"])
expect(document).toHaveProperty(["schema", "properties", "keybinds", "anyOf", 0, "properties", "app.exit"], {
anyOf: [{ $ref: "#/$defs/TuiKeybind.BindingValue" }, { type: "null" }],
description: "Exit the application",
})
expect(document).toHaveProperty(["schema", "properties", "scroll", "anyOf", 0, "properties", "speed", "anyOf", 0], {
type: "number",
minimum: 0.001,
})
expect(document).toHaveProperty(
["schema", "properties", "attention", "anyOf", 0, "properties", "volume", "anyOf", 0],
{ type: "number", minimum: 0, maximum: 1 },
)
})
test("includes the published schema when creating cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the schema in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, JSON.stringify({ $schema: "https://opencode.ai/v2/cli.json", animations: true }))
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(file).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates tui and kv config into cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(
@@ -73,6 +134,7 @@ test("migrates tui and kv config into cli.json", async () => {
)
expect(config).toMatchObject({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy", mode: "light" },
keybinds: {
leader: "ctrl+o",
@@ -128,8 +190,14 @@ test("migrates before the first update and does not remigrate afterward", async
}),
)
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
expect(config).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
})
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
describe("debug paths command", () => {
test("is included in troubleshooting help", async () => {
const [debug, paths] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "paths", "--help"])])
expect(debug.exitCode).toBe(0)
expect(debug.stdout).toContain("paths")
expect(debug.stdout).toContain("Show global paths (data, config, cache, state)")
expect(paths.exitCode).toBe(0)
expect(paths.stdout).toContain("opencode debug paths [flags]")
})
test("prints resolved global paths without starting a server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-paths-"))
try {
const result = await cli(["debug", "paths"], {
XDG_DATA_HOME: path.join(root, "data"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
})
const paths = Object.fromEntries(
result.stdout
.trim()
.split("\n")
.map((line) => line.trim().split(/\s+/, 2)),
)
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(paths).toMatchObject({
home: os.homedir(),
data: path.join(root, "data", "opencode"),
config: path.join(root, "config", "opencode"),
cache: path.join(root, "cache", "opencode"),
state: path.join(root, "state", "opencode"),
bin: path.join(root, "cache", "opencode", "bin"),
log: path.join(root, "data", "opencode", "log"),
repos: path.join(root, "data", "opencode", "repos"),
})
expect(paths.tmp).toBeTruthy()
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
})
async function cli(args: string[], env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
+1
View File
@@ -8,6 +8,7 @@ test("collects each SEA asset key once", async () => {
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
+6 -2
View File
@@ -120,6 +120,7 @@ function nodePrelude(input: NodeBuildInput) {
input.target.platform === "darwin"
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
: undefined
const opencodePtyAsset = input.target.opencodePtyAsset
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
@@ -200,13 +201,17 @@ if (__ocIsSea()) {
const __ocAssetRoot = __ocIsSea()
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
if (__ocIsSea()) {
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
for (const __ocKey of __ocAssetKeys()) {
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
if (__ocExists(__ocTarget)) continue
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
__ocChmod(__ocTemporary, 0o755)
try {
__ocRename(__ocTemporary, __ocTarget)
} catch (__ocError) {
@@ -214,8 +219,6 @@ if (__ocIsSea()) {
if (!__ocExists(__ocTarget)) throw __ocError
}
}
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
}
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
@@ -227,6 +230,7 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
try {
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
} catch {}
+10
View File
@@ -1333,6 +1333,15 @@ export type CredentialUpdateOperation<E = never> = (
input: CredentialUpdateInput,
) => Effect.Effect<CredentialUpdateOutput, E>
export type CredentialActivateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type CredentialActivateOutput = void
export type CredentialActivateOperation<E = never> = (
input: CredentialActivateInput,
) => Effect.Effect<CredentialActivateOutput, E>
export type CredentialRemoveInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -1344,6 +1353,7 @@ export type CredentialRemoveOperation<E = never> = (
export interface CredentialApi<E = never> {
readonly update: CredentialUpdateOperation<E>
readonly activate: CredentialActivateOperation<E>
readonly remove: CredentialRemoveOperation<E>
}
@@ -138,6 +138,8 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
@@ -935,6 +937,14 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
preserveEffect<CredentialActivateOutput>()(
raw["credential.activate"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
preserveEffect<CredentialRemoveOutput>()(
raw["credential.remove"]({
@@ -945,6 +955,7 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
update: EndpointCredentialUpdate(raw),
activate: EndpointCredentialActivate(raw),
remove: EndpointCredentialRemove(raw),
})
+2
View File
@@ -4,10 +4,12 @@ export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type GenerateApi = Client["generate"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type PermissionApi = Client["permission"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
@@ -132,6 +132,8 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
@@ -1270,6 +1272,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
activate: (input: CredentialActivateInput, requestOptions?: RequestOptions) =>
request<CredentialActivateOutput>(
{
method: "POST",
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialRemoveOutput>(
{
+57 -11
View File
@@ -476,7 +476,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
@@ -823,6 +823,24 @@ export type ModelsDevRefreshed = {
data: {}
}
export type CredentialUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "credential.updated"
location?: LocationRef
data: {}
}
export type CredentialSwitched = {
id: string
created: number
metadata?: { [x: string]: any }
type: "credential.switched"
location?: LocationRef
data: { integrationID: string; credentialID: string | null }
}
export type IntegrationUpdated = {
id: string
created: number
@@ -832,15 +850,6 @@ export type IntegrationUpdated = {
data: {}
}
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
}
export type CatalogUpdated = {
id: string
created: number
@@ -1310,8 +1319,10 @@ export type ToolContent1 = ToolTextContent | ToolFileContent1
export type ModelCompatibility = {
reasoningField?: ModelReasoningField
requireReasoning?: boolean
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
}
export type ModelCost = {
@@ -1407,6 +1418,24 @@ export type Project = {
sandboxes: Array<string>
}
export type ProjectUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.updated"
location?: LocationRef
data: {
id: string
canonical: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
commands?: ProjectCommands
time: ProjectTime
sandboxes: Array<string>
}
}
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
@@ -1417,6 +1446,7 @@ export type PermissionRequest = {
save?: Array<string>
metadata?: { [x: string]: JsonValue }
source?: PermissionSource
message?: string
}
export type PermissionAsked = {
@@ -1433,6 +1463,7 @@ export type PermissionAsked = {
save?: Array<string>
metadata?: { [x: string]: any }
source?: PermissionSource
message?: string
}
}
@@ -2102,14 +2133,16 @@ export type SessionMessagesResponse = {
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type V2Event =
| ModelsDevRefreshed
| CredentialUpdated
| CredentialSwitched
| IntegrationUpdated
| IntegrationConnectionUpdated
| CatalogUpdated
| AgentUpdated
| SessionCreated
@@ -2164,6 +2197,7 @@ export type V2Event =
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
| WorktreeResolved
| CommandUpdated
@@ -2706,6 +2740,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -2981,6 +3016,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3256,6 +3292,7 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -4349,6 +4386,15 @@ export type CredentialUpdateInput = {
export type CredentialUpdateOutput = void
export type CredentialActivateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CredentialActivateOutput = void
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
+33
View File
@@ -521,6 +521,9 @@ export function createData(config: CreateDataInput) {
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
case "project.updated":
setStore("project", "info", event.data.id, reconcile(event.data))
return
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
@@ -1045,6 +1048,36 @@ export function createData(config: CreateDataInput) {
return
}
if (event.type === "credential.updated" || event.type === "credential.switched") {
Object.keys(store.location).forEach((key) => {
const ref = JSON.parse(key) as [string, string | null]
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
if (event.type === "credential.updated") {
result.location.integration.invalidate(location)
void result.location.integration.sync(location)
return
}
setStore("location", key, (data) => ({
...data,
integration: data?.integration?.map((integration) => {
if (integration.id !== event.data.integrationID) return integration
const active = integration.connections.find(
(connection) => connection.type === "credential" && connection.id === event.data.credentialID,
)
if (!active) return integration
return {
...integration,
connections: [active, ...integration.connections.filter((connection) => connection !== active)],
}
}),
}))
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
})
return
}
if (!event.location) return
const location = event.location
switch (event.type) {
+19
View File
@@ -26,6 +26,7 @@ test("exposes every standard HTTP API group", () => {
"skill",
"event",
"pty",
"experimental",
"shell",
"reference",
"worktree",
@@ -187,6 +188,24 @@ test("experimental wellknown integration add uses the public HTTP contract", asy
expect(await request?.json()).toEqual({ url: "https://example.com" })
})
test("credential.activate uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
await client.credential.activate({ credentialID: "cred_work", location: { directory: "/tmp/project" } })
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"http://localhost:3000/api/credential/cred_work/activate?location%5Bdirectory%5D=%2Ftmp%2Fproject",
)
})
test("integration connections optionally submit a form answer", async () => {
const requests: Request[] = []
const client = OpenCode.make({
+180 -1
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
const session = (viewed: number): SessionInfo => ({
id: "ses_refresh",
@@ -72,6 +72,185 @@ test("revalidates after an event overtakes an active session read", async () =>
}
})
test("updates authoritative cached project metadata from live events", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const original: Project = {
id: "project_renamed",
canonical: "/projects/original",
name: "Original custom name",
time: { created: 1, updated: 1 },
sandboxes: [],
}
const unrelated: Project = {
id: "project_unrelated",
canonical: "/projects/unrelated",
name: "Unrelated project",
time: { created: 1, updated: 1 },
sandboxes: [],
}
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/project")) throw new Error(`Unexpected request: ${request.url}`)
requests++
return Response.json([original, unrelated])
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/projects/original", event }),
dispose,
}))
try {
await setup.data.project.sync()
expect(setup.data.project.get(original.id)).toEqual(original)
const updated: OpenCodeEvent = {
id: "evt_project_renamed",
created: 2,
type: "project.updated",
data: {
...original,
canonical: "/projects/renamed",
name: "Updated custom name",
time: { ...original.time, updated: 2 },
},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed")
expect(setup.data.project.get(original.id)?.name).toBe("Updated custom name")
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
expect(requests).toBe(1)
const reset: OpenCodeEvent = {
id: "evt_project_name_reset",
created: 3,
type: "project.updated",
data: {
id: original.id,
canonical: "/projects/renamed-again",
time: { ...original.time, updated: 3 },
sandboxes: [],
},
}
listeners.forEach((listener) => listener({ name: reset.type, details: reset }))
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed-again")
expect(setup.data.project.get(original.id)?.name).toBeUndefined()
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
expect(requests).toBe(1)
} finally {
setup.dispose()
}
})
test("refreshes global credential events across every loaded location and workspace", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
return Response.json({
location: {
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "project", directory, canonical: directory },
},
data: [],
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
const locations = [{ directory: "/project" }, { directory: "/other", workspaceID: "workspace-other" }]
try {
await Promise.all(
locations.flatMap((location) => [
setup.data.location.integration.sync(location),
setup.data.location.model.sync(location),
setup.data.location.provider.sync(location),
]),
)
requests.length = 0
const updated: OpenCodeEvent = {
id: "evt_credential.updated",
created: 1,
type: "credential.updated",
data: {},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
await wait(() => requests.length === 2)
expect(
requests.map((url) => [
url.pathname,
url.searchParams.get("location[directory]"),
url.searchParams.get("location[workspace]"),
]),
).toEqual([
["/api/integration", "/project", null],
["/api/integration", "/other", "workspace-other"],
])
requests.length = 0
for (const credentialID of ["credential", null]) {
const switched: OpenCodeEvent = {
id: `evt_credential.switched.${credentialID}`,
created: 2,
type: "credential.switched",
data: { credentialID, integrationID: "integration" },
}
listeners.forEach((listener) => listener({ name: switched.type, details: switched }))
await wait(() => requests.length === 4)
expect(
requests.map((url) => [
url.pathname,
url.searchParams.get("location[directory]"),
url.searchParams.get("location[workspace]"),
]),
).toEqual(
expect.arrayContaining([
["/api/model", "/project", null],
["/api/provider", "/project", null],
["/api/model", "/other", "workspace-other"],
["/api/provider", "/other", "workspace-other"],
]),
)
requests.length = 0
}
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+7 -4
View File
@@ -22,7 +22,7 @@
"benchmark:location": "bun run script/benchmark-location.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun test --only-failures",
"test": "bun run script/test.ts",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
},
"exports": {
@@ -41,6 +41,12 @@
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
},
"#persistent-pty-binary": {
"workerd": "./src/persistent-pty/binary.workerd.ts",
"bun": "./src/persistent-pty/binary.bun.ts",
"node": "./src/persistent-pty/binary.node.ts",
"default": "./src/persistent-pty/binary.bun.ts"
},
"#fff": {
"workerd": "./src/filesystem/fff.workerd.ts",
"bun": "./src/filesystem/fff.bun.ts",
@@ -96,9 +102,7 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
@@ -107,7 +111,6 @@
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@lydell/node-pty": "catalog:",
+52
View File
@@ -0,0 +1,52 @@
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../test/fixture/tmpdir"
await using directory = await tmpdir("oc-")
const home = directory.path
const temporary = path.join(home, "tmp")
await fs.mkdir(temporary)
const environment = {
...Object.fromEntries(
Object.entries(process.env).filter(([name]) => {
if (process.env.RECORD === "true" && name === "OPENAI_API_KEY") return true
if (
/^(?:AWS|AZURE|GOOGLE|GCP|GCLOUD|VERTEX|OPENAI|ANTHROPIC|GEMINI|XAI|CLOUDFLARE|CF_AIG|SNOWFLAKE|AICORE|GITLAB|NPM_CONFIG)_/i.test(
name,
)
) {
return false
}
return !/(?:^|_)(?:API_KEY|AUTHORIZATION|TOKEN|SECRET|PASSWORD|CREDENTIALS?)$/i.test(name)
}),
),
HOME: home,
OPENCODE_TEST_HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_DATA_HOME: path.join(home, ".local", "share"),
XDG_CACHE_HOME: path.join(home, ".cache"),
XDG_STATE_HOME: path.join(home, ".local", "state"),
OPENCODE_CONFIG_DIR: path.join(home, ".config", "opencode"),
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
TMPDIR: temporary,
...(process.platform === "win32" ? { USERPROFILE: home, TMP: temporary, TEMP: temporary } : {}),
}
const child = Bun.spawn({
cmd: [process.execPath, "test", "--only-failures", ...process.argv.slice(2)],
env: environment,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
const interrupt = () => child.kill("SIGINT")
const terminate = () => child.kill("SIGTERM")
process.once("SIGINT", interrupt)
process.once("SIGTERM", terminate)
const result = await child.exited
process.off("SIGINT", interrupt)
process.off("SIGTERM", terminate)
process.exitCode = result
+12
View File
@@ -53,6 +53,18 @@ export function map(input: MapInput): Mapping | undefined {
...mapOpenAIOptions(input.settings),
},
}
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/togetherai":
return {
package: `@opencode-ai/ai/providers/${input.packageName.slice("@ai-sdk/".length)}`,
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "fetch", "headers", "name"]),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
+11 -1
View File
@@ -34,6 +34,7 @@ import {
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { makeParser } from "effect/unstable/encoding/Sse"
import type { ID, Info } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
@@ -65,15 +66,23 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
const reader = res.body.getReader()
const decoder = new TextDecoder()
let deadline: number | undefined
const parser = makeParser((event) => {
if (event._tag === "Event") deadline = Date.now() + ms
})
const body = new ReadableStream<Uint8Array>({
async pull(ctrl) {
const expires = deadline ?? Date.now() + ms
deadline = expires
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
const remaining = Math.max(0, expires - Date.now())
const id = setTimeout(() => {
const err = new Error("SSE read timed out")
ctl.abort(err)
void reader.cancel(err)
reject(err)
}, ms)
}, remaining)
reader.read().then(
(part) => {
@@ -92,6 +101,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
return
}
parser.feed(decoder.decode(part.value, { stream: true }))
ctrl.enqueue(part.value)
},
async cancel(reason) {
+13 -10
View File
@@ -89,6 +89,7 @@ export interface PublishOptions {
readonly id?: Event.ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
readonly global?: boolean
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void>
}
@@ -446,11 +447,12 @@ export function configured(options?: Options) {
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
return yield* publishEvent(
definition,
{
@@ -480,11 +482,12 @@ export function configured(options?: Options) {
}),
)
}
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
return {
definition,
aggregateID,
+2 -5
View File
@@ -16,7 +16,6 @@ import {
type Entry,
Event,
} from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
@@ -159,9 +158,7 @@ export const layer = (options?: Options) =>
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
@@ -343,7 +340,7 @@ export const layer = (options?: Options) =>
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
+105 -8
View File
@@ -1,10 +1,11 @@
export * as Credential from "./credential.js"
import { asc, eq } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database.js"
import { Bus } from "./bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql.js"
@@ -20,6 +21,8 @@ export type Key = Credential.Key
export const Value = Credential.Value
export type Value = Credential.Value
export const Event = Credential.Event
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: Integration.ID,
@@ -34,12 +37,14 @@ export interface Interface {
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */
/** Creates a credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<Info>
/** Selects a stored credential for its integration. */
readonly activate: (id: ID) => Effect.Effect<void>
/** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */
@@ -52,6 +57,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
@@ -73,7 +79,7 @@ const layer = Layer.effect(
db
.select()
.from(CredentialTable)
.orderBy(asc(CredentialTable.time_created))
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
@@ -82,7 +88,7 @@ const layer = Layer.effect(
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, integrationID))
.orderBy(asc(CredentialTable.time_created))
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
@@ -101,7 +107,8 @@ const layer = Layer.effect(
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.delete(CredentialTable)
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integrationID))
.run()
yield* tx
@@ -111,27 +118,117 @@ const layer = Layer.effect(
integration_id: credential.integrationID,
label: credential.label,
value: credential.value,
active: true,
})
.run()
}),
)
.pipe(Effect.orDie)
yield* bus.publish(Event.Updated, {}, { global: true })
yield* bus.publish(
Event.Switched,
{ integrationID: credential.integrationID, credentialID: credential.id },
{ global: true },
)
return credential
}),
activate: Effect.fn("Credential.activate")(function* (id) {
const integrationID = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential?.integration_id) return
const active = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
if (active?.id === id) return
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integration_id))
.run()
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run()
return credential.integration_id
}),
)
.pipe(Effect.orDie)
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (!updates.label && !updates.value) return
if (updates.label === undefined && updates.value === undefined) return
const credential = yield* db
.select({ integrationID: CredentialTable.integration_id, label: CredentialTable.label })
.from(CredentialTable)
.where(eq(CredentialTable.id, id))
.get()
.pipe(Effect.orDie)
if (!credential?.integrationID) return
if (updates.label === credential.label && updates.value === undefined) return
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
.where(eq(CredentialTable.id, id))
.run()
.pipe(Effect.orDie)
if (updates.label !== undefined && updates.label !== credential.label)
yield* bus.publish(Event.Updated, {}, { global: true })
}),
remove: Effect.fn("Credential.remove")(function* (id) {
yield* db.delete(CredentialTable).where(eq(CredentialTable.id, id)).run().pipe(Effect.orDie)
const removed = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential) return
const active = credential.integration_id
? yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
: undefined
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
const replacement = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
if (replacement) {
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integration_id))
.run()
yield* tx
.update(CredentialTable)
.set({ active: true })
.where(eq(CredentialTable.id, replacement.id))
.run()
}
return {
switched: true as const,
integrationID: credential.integration_id,
credentialID: replacement?.id ?? null,
}
}),
)
.pipe(Effect.orDie)
if (!removed) return
yield* bus.publish(Event.Updated, {}, { global: true })
if (removed.switched)
yield* bus.publish(
Event.Switched,
{ integrationID: removed.integrationID, credentialID: removed.credentialID },
{ global: true },
)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
@@ -1,14 +0,0 @@
/* oxlint-disable */
import type { MigrationConfig } from "drizzle-orm/migrator"
import { readMigrationFiles } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { migrate as coreMigrate } from "../sqlite-core/effect/session.js"
import type { EffectSQLiteDatabase } from "./driver.js"
export function migrate<TRelations extends AnyRelations>(
db: EffectSQLiteDatabase<TRelations>,
config: MigrationConfig,
) {
const migrations = readMigrationFiles(config)
return coreMigrate(migrations, db.session, config)
}
@@ -1,6 +1,5 @@
export { EffectLogger } from "drizzle-orm/effect-core"
export * from "./effect-sqlite/driver.js"
export * from "./effect-sqlite/session.js"
export { migrate } from "./effect-sqlite/migrator.js"
export * as EffectDrizzleSqlite from "./index.js"
@@ -5,13 +5,10 @@ import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import { NoopCache, strategyFor } from "drizzle-orm/cache/core/cache"
import type { WithCacheConfig } from "drizzle-orm/cache/core/types"
import { MigratorInitError } from "drizzle-orm/effect-core/errors"
import { EffectDrizzleQueryError, EffectTransactionRollbackError } from "drizzle-orm/effect-core/errors"
import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger"
import type { QueryEffectHKTBase, QueryEffectKind } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { MigrationConfig, MigrationMeta } from "drizzle-orm/migrator"
import { getMigrationsToRun } from "drizzle-orm/migrator.utils"
import type {
AnyRelations,
EmptyRelations,
@@ -20,17 +17,14 @@ import type {
} from "drizzle-orm/relations"
import { makeJitRqbMapper } from "drizzle-orm/relations"
import type { PreparedQuery } from "drizzle-orm/session"
import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql"
import { fillPlaceholders, type Query, type SQL } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite.js"
import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils"
import { mapResultRow, resolveNullableObjectPaths } from "../../internal/drizzle-utils.js"
import { SQLiteEffectDatabase } from "./db.js"
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
type SQLiteEffectExecuteMethod = SQLiteExecuteMethod | "values"
export class SQLiteEffectPreparedQuery<
@@ -429,69 +423,3 @@ export abstract class SQLiteEffectTransaction<
return new EffectTransactionRollbackError()
}
}
export const migrate = Effect.fn("migrate")(function* <TEffectHKT extends QueryEffectHKTBase>(
migrations: MigrationMeta[],
session: SQLiteEffectSession<TEffectHKT>,
config: string | MigrationConfigWithInit,
) {
const migrationsTable =
typeof config === "string" ? "__drizzle_migrations" : (config.migrationsTable ?? "__drizzle_migrations")
const { newDb } = yield* upgradeIfNeeded(migrationsTable, session, migrations)
if (newDb) {
yield* session.run(sql`
CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsTable)} (
id INTEGER PRIMARY KEY,
hash text NOT NULL,
created_at numeric,
name text,
applied_at TEXT
)
`)
}
const dbMigrations = yield* session.all<{ id: number; hash: string; created_at: string; name: string | null }>(
sql`SELECT id, hash, created_at, name FROM ${sql.identifier(migrationsTable)}`,
)
if (typeof config === "object" && config.init) {
if (dbMigrations.length) {
return yield* new MigratorInitError({ exitCode: "databaseMigrations" })
}
if (migrations.length > 1) {
return yield* new MigratorInitError({ exitCode: "localMigrations" })
}
const [migration] = migrations
if (!migration) return
yield* session.run(
sql`insert into ${sql.identifier(
migrationsTable,
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
)
return
}
const migrationsToRun = getMigrationsToRun({ localMigrations: migrations, dbMigrations })
if (migrationsToRun.length === 0) return
yield* session.transaction((tx) =>
Effect.gen(function* () {
for (const migration of migrationsToRun) {
for (const stmt of migration.sql) {
yield* tx.run(sql.raw(stmt))
}
yield* tx.run(
sql`insert into ${sql.identifier(
migrationsTable,
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
)
}
}),
)
})
@@ -1,102 +0,0 @@
/* oxlint-disable */
import * as Effect from "effect/Effect"
import type { SqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleError } from "drizzle-orm/effect-core/errors"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import type { MigrationMeta } from "drizzle-orm/migrator"
import { sql } from "drizzle-orm/sql/sql"
import type { SQLiteEffectSession } from "../sqlite-core/effect/session.js"
import {
buildSQLiteMigrationBackfillStatements,
prepareSQLiteMigrationBackfill,
type SQLiteMigrationTableRow,
} from "./sqlite.js"
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils.js"
const migrationUpgradeError = (cause: unknown) =>
new EffectDrizzleError({
message:
typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string"
? cause.message
: String(cause),
cause,
})
export const upgradeIfNeeded: <TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) => Effect.Effect<UpgradeResult, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> =
Effect.fn("upgradeIfNeeded")(function* <TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) {
const tableExists = yield* session.all(
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
)
if (tableExists.length === 0) {
return { newDb: true }
}
const rows = yield* session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeFunctions[v]
if (!upgradeFn) {
return yield* new EffectDrizzleError({
message: `No upgrade path from migration table version ${v} to ${v + 1}`,
cause: { version: v },
})
}
yield* upgradeFn(migrationsTable, session, localMigrations)
}
return { newDb: false }
})
const upgradeFunctions: Record<
number,
<TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) => Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]>
> = {
0: upgradeFromV0,
}
function upgradeFromV0<TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
): Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> {
return Effect.gen(function* () {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = yield* session.all<SQLiteMigrationTableRow>(
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
)
const statements = yield* Effect.try({
try: () =>
buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
),
catch: migrationUpgradeError,
})
yield* session.transaction((tx) =>
Effect.gen(function* () {
for (const statement of statements) {
yield* tx.run(statement)
}
}),
)
})
}
@@ -1,105 +0,0 @@
/* oxlint-disable */
import type { MigrationMeta } from "drizzle-orm/migrator"
import { type SQL, sql } from "drizzle-orm/sql/sql"
/** @internal */
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
type SQLiteMigrationBackfillEntry = {
name: string
selector:
| { column: "id"; value: number }
| { column: "created_at"; value: number }
| { column: "hash"; value: string }
}
function unmatchedMigrationError(unmatched: SQLiteMigrationTableRow[]) {
return new Error(
`While upgrading your database migrations table we found ${unmatched.length} (${unmatched
.map((it) => `[id: ${it.id}, created_at: ${it.created_at}]`)
.join(
", ",
)}) migrations in the database that do not match any local migration. This means that some migrations were applied to the database but are missing from the local environment`,
)
}
/** @internal */
export function prepareSQLiteMigrationBackfill(
dbRows: SQLiteMigrationTableRow[],
localMigrations: MigrationMeta[],
): SQLiteMigrationBackfillEntry[] {
const sortedLocalMigrations = [...localMigrations].sort((a, b) =>
a.folderMillis !== b.folderMillis ? a.folderMillis - b.folderMillis : (a.name ?? "").localeCompare(b.name ?? ""),
)
const byMillis = new Map<number, MigrationMeta[]>()
const byHash = new Map<string, MigrationMeta>()
for (const migration of sortedLocalMigrations) {
if (!byMillis.has(migration.folderMillis)) {
byMillis.set(migration.folderMillis, [])
}
byMillis.get(migration.folderMillis)!.push(migration)
byHash.set(migration.hash, migration)
}
const toApply: SQLiteMigrationBackfillEntry[] = []
const unmatched: SQLiteMigrationTableRow[] = []
for (const dbRow of dbRows) {
const stringified = String(dbRow.created_at)
const millis = Number(stringified.substring(0, stringified.length - 3) + "000")
const candidates = byMillis.get(millis)
const matchedByMillis = candidates?.length === 1 ? candidates[0] : undefined
const matchedByCandidateHash =
candidates && candidates.length > 1
? candidates.find((candidate) => candidate.hash && dbRow.hash && candidate.hash === dbRow.hash)
: undefined
const matchedByHash = matchedByMillis || matchedByCandidateHash ? undefined : byHash.get(dbRow.hash)
const matched = matchedByMillis ?? matchedByCandidateHash ?? matchedByHash
if (matched) {
toApply.push({
name: matched.name,
selector:
dbRow.id !== null
? { column: "id", value: dbRow.id }
: matchedByMillis
? { column: "created_at", value: dbRow.created_at }
: { column: "hash", value: dbRow.hash },
})
continue
}
unmatched.push(dbRow)
}
if (unmatched.length > 0) {
throw unmatchedMigrationError(unmatched)
}
return toApply
}
/** @internal */
export function buildSQLiteMigrationBackfillStatements(
migrationsTable: string,
backfillEntries: SQLiteMigrationBackfillEntry[],
) {
const table = sql`${sql.identifier(migrationsTable)}`
const statements: SQL[] = [
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("name")} text`,
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("applied_at")} TEXT`,
]
for (const backfillEntry of backfillEntries) {
const updateQuery = sql`UPDATE ${table} SET ${sql.identifier("name")} = ${backfillEntry.name}, ${sql.identifier(
"applied_at",
)} = NULL WHERE`
updateQuery.append(sql` ${sql.identifier(backfillEntry.selector.column)} = ${backfillEntry.selector.value}`)
statements.push(updateQuery)
}
return statements
}
@@ -1,45 +0,0 @@
/* oxlint-disable */
export interface UpgradeResult {
newDb: boolean
}
export const MIGRATIONS_TABLE_VERSIONS = {
sqlite: 1,
pg: 1,
effect: 1,
mysql: 1,
mssql: 1,
cockroach: 1,
singlestore: 1,
} as const
export const GET_VERSION_FOR = {
mysql: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
pg: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
effect: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
mssql: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
cockroach: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
singlestore: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
sqlite: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
} as const
+33 -6
View File
@@ -66,12 +66,39 @@ export function applyOnly(db: Database, input: Migration[]) {
if (
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
const named = (yield* db.all<{ name: string }>(
sql`SELECT name FROM pragma_table_info('__drizzle_migrations')`,
)).some((column) => column.name === "name")
if (named) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
}
if (!named) {
const entries = yield* db.all<{ created_at: number; prefix: string | null }>(sql`
SELECT created_at, strftime('%Y%m%d%H%M%S', created_at / 1000, 'unixepoch') AS prefix
FROM ${sql.identifier("__drizzle_migrations")}
WHERE created_at IS NOT NULL
`)
for (const entry of entries) {
const migration = input.find((item) => item.id.startsWith(`${entry.prefix}_`))
if (!migration) {
return yield* Effect.die(
new Error(`Legacy migration timestamp ${entry.created_at} does not match any known migration`),
)
}
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
VALUES (${migration.id}, ${Date.now()})
`)
}
}
completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
@@ -5,6 +5,9 @@ const migration: DatabaseMigration.Migration = {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
const columns = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`workspace\`)`)
const name = columns.some((column) => column.name === "name") ? "`name`" : "''"
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
@@ -19,7 +22,7 @@ const migration: DatabaseMigration.Migration = {
);
`)
yield* tx.run(
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, ${name}, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
+33 -30
View File
@@ -166,6 +166,8 @@ export interface Interface extends State.Transformable<Draft> {
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
/** Selects a stored credential as the active integration connection. */
readonly activate: (credentialID: Credential.ID) => Effect.Effect<void>
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
@@ -329,6 +331,17 @@ const layer = Layer.effect(
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
if (input.label !== undefined) return yield* credentials.create(input)
const name = state.get().integrations.get(input.integrationID)?.ref.name ?? input.integrationID
const labels = new Set((yield* credentials.list(input.integrationID)).map((credential) => credential.label))
const label =
Array.from({ length: labels.size + 1 }, (_, index) => (index === 0 ? name : `${name} ${index + 1}`)).find(
(candidate) => !labels.has(candidate),
) ?? name
return yield* credentials.create({ ...input, label })
})
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved
.map((credential) => ({
@@ -348,6 +361,7 @@ const layer = Layer.effect(
Info.make({
id: entry.ref.id,
name: entry.ref.name,
...(entry.ref.metadata === undefined ? {} : { metadata: entry.ref.metadata }),
methods: entry.methods,
connections,
})
@@ -394,7 +408,7 @@ const layer = Layer.effect(
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
credentials.create({
createCredential({
integrationID: attempt.integrationID,
label,
value: exit.value,
@@ -421,8 +435,6 @@ const layer = Layer.effect(
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
@@ -452,13 +464,11 @@ const layer = Layer.effect(
return
}
const persistence = yield* credentials
.create({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
})
.pipe(Effect.asVoid, Effect.exit)
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
@@ -476,9 +486,6 @@ const layer = Layer.effect(
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
if (Exit.isFailure(persistence)) return
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
)
})
@@ -702,7 +709,7 @@ const layer = Layer.effect(
if (!method.form && Object.keys(answer).length > 0) {
return yield* new AuthorizationError({ cause: new Error("Key method does not accept a form answer") })
}
yield* credentials.create({
yield* createCredential({
integrationID: input.integrationID,
label: input.label,
value: Credential.Key.make({
@@ -711,25 +718,21 @@ const layer = Layer.effect(
...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
}),
})
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
activate: Effect.fn("Integration.connection.activate")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.update(credentialID, updates)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.remove(credentialID)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
if (!credential) return
const active = resolveConnections(
state.get().integrations.get(credential.integrationID),
yield* credentials.list(credential.integrationID),
)[0]
if (active?.type === "credential" && active.id === credentialID) return
yield* credentials.activate(credentialID)
}),
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
credentials.update(credentialID, updates),
),
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
},
oauth: {
connect: connectOAuth,
+7 -10
View File
@@ -140,8 +140,7 @@ export type Draft = {
remove: (server: ServerName | string) => void
}
const cloneConfig = (config: Mcp.ServerConfig) =>
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
export interface Interface extends State.Transformable<Draft> {
readonly servers: () => Effect.Effect<ServerInfo[]>
@@ -229,6 +228,7 @@ export const layer = (options?: Options) =>
.transform((draft) => {
draft.update(integrationID, (ref) => {
ref.name = name
ref.metadata = { source: "mcp" }
})
draft.method.update({
integrationID,
@@ -264,8 +264,7 @@ export const layer = (options?: Options) =>
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => {},
}
const stored = yield* credentials.list(entry.integrationID)
const found = stored.find((credential) => credential.value.type === "oauth")
const found = (yield* credentials.list(entry.integrationID)).at(-1)
if (!found || found.value.type !== "oauth")
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
@@ -288,8 +287,8 @@ export const layer = (options?: Options) =>
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
// connection may have already replaced ours; deleting then would discard the newer valid credential and
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
// reconnects remain serialized by the server lock.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
const oauth = await readOAuthCredential()
@@ -673,7 +672,7 @@ export const layer = (options?: Options) =>
}).pipe(locks.withLock(name))
})
fork(
bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => owned.has(event.data.integrationID)),
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
Effect.ignore,
@@ -687,9 +686,7 @@ export const layer = (options?: Options) =>
config === false ? [] : [[name, cloneConfig(config)] as const],
),
),
removed: new Set(
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
),
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
}),
draft: (draft) => ({
list: () => Array.from(draft.servers),
+6
View File
@@ -333,8 +333,11 @@ function usesAPIKeyAuth(packageName: string | undefined) {
return (
name === "@ai-sdk/openai" ||
name === "@ai-sdk/anthropic" ||
name === "@ai-sdk/cerebras" ||
name === "@ai-sdk/deepinfra" ||
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/togetherai" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
name === "@ai-sdk/azure" ||
@@ -342,8 +345,11 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
name === "@opencode-ai/ai/providers/anthropic" ||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
name === "@opencode-ai/ai/providers/cerebras" ||
name === "@opencode-ai/ai/providers/deepinfra" ||
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/togetherai" ||
name === "@opencode-ai/ai/providers/xai" ||
name === "@opencode-ai/ai/providers/openrouter" ||
name === "@opencode-ai/ai/providers/azure" ||
+49 -56
View File
@@ -11,6 +11,7 @@ import { SessionSchema } from "./session/schema.js"
import { SessionStore } from "./session/store.js"
import { Wildcard } from "./util/wildcard.js"
import { PermissionSaved } from "./permission/saved.js"
import { PluginHooks } from "./plugin/hooks.js"
const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
@@ -70,9 +71,10 @@ export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission
rules: Permission.Ruleset,
permission: Schema.String,
resources: Schema.Array(Schema.String),
reason: Schema.String.pipe(Schema.optional),
}) {
override get message() {
return `Permission denied: ${this.permission}`
return this.reason ?? `Permission denied: ${this.permission}`
}
}
@@ -99,11 +101,6 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly allowsAll: (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -128,6 +125,7 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
@@ -159,24 +157,6 @@ const layer = Layer.effect(
return agent?.permissions ?? missingAgentPermissions
})
const allowsAll = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) {
const rules = yield* configured(input.sessionID, input.agent)
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
for (let index = relevant.length - 1; index >= 0; index--) {
const rule = relevant[index]
if (rule.resource !== "*") {
if (rule.effect !== "allow") return false
continue
}
return rule.effect === "allow"
}
return false
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
@@ -191,10 +171,19 @@ const layer = Layer.effect(
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
agent: input.agent,
action: input.action,
resources: input.resources,
metadata: input.metadata,
source: input.source,
effect,
})
return { effect: event.effect, message: event.message, rules: all }
})
function request(input: AssertInput): Request {
function request(input: AssertInput, message?: string): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
@@ -203,6 +192,7 @@ const layer = Layer.effect(
save: input.save,
metadata: input.metadata,
source: input.source,
message,
}
}
@@ -223,39 +213,42 @@ const layer = Layer.effect(
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
return { id: value.id, effect: result.effect }
})
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
),
Effect.gen(function* () {
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
reason: result.message,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input, result.message), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
)
}),
)
const reply = Effect.fn("Permission.reply")((input: ReplyInput) =>
@@ -337,12 +330,12 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node],
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
})

Some files were not shown because too many files have changed in this diff Show More