Compare commits

..

5 Commits

Author SHA1 Message Date
Aiden Cline ab03b05c00 fix(ai): replay function call item ids 2026-08-07 22:30:52 -05:00
Aiden Cline 0f67def34d fix(ai): keep response ids provider-owned 2026-08-07 17:15:00 -05:00
Aiden Cline 0657dcbad2 fix(ai): stop generating response item ids 2026-08-07 14:26:50 -05:00
Aiden Cline 82afcfd4e0 fix(ai): generate uuidv7 item ids 2026-08-07 14:04:15 -05:00
Aiden Cline a1cbcc8641 fix(ai): preserve responses item ids 2026-08-07 12:57:48 -05:00
537 changed files with 25738 additions and 28717 deletions
-1
View File
@@ -4,7 +4,6 @@ on:
push: push:
branches: branches:
- dev - dev
- v2
jobs: jobs:
generate: generate:
+1
View File
@@ -251,3 +251,4 @@ opencode-drive stop --name demo
```bash ```bash
opencode-drive dir --name demo opencode-drive dir --name demo
``` ```
-56
View File
@@ -571,20 +571,6 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
}, },
}, },
"packages/merman": {
"name": "@opencode-ai/merman",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"string-width": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.18.8", "version": "1.18.8",
@@ -693,7 +679,6 @@
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"modal": "0.9.0",
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
@@ -896,7 +881,6 @@
"dependencies": { "dependencies": {
"@opencode-ai/client": "workspace:*", "@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/merman": "workspace:*",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",
@@ -1528,18 +1512,6 @@
"@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="], "@capsizecss/unpack": ["@capsizecss/unpack@2.4.0", "", { "dependencies": { "blob-to-buffer": "^1.2.8", "cross-fetch": "^3.0.4", "fontkit": "^2.0.2" } }, "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q=="],
"@cbor-extract/cbor-extract-darwin-arm64": ["@cbor-extract/cbor-extract-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA=="],
"@cbor-extract/cbor-extract-darwin-x64": ["@cbor-extract/cbor-extract-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q=="],
"@cbor-extract/cbor-extract-linux-arm": ["@cbor-extract/cbor-extract-linux-arm@2.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ=="],
"@cbor-extract/cbor-extract-linux-arm64": ["@cbor-extract/cbor-extract-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg=="],
"@cbor-extract/cbor-extract-linux-x64": ["@cbor-extract/cbor-extract-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA=="],
"@cbor-extract/cbor-extract-win32-x64": ["@cbor-extract/cbor-extract-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA=="],
"@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="], "@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
@@ -1748,10 +1720,6 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="], "@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
@@ -1846,8 +1814,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
"@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="], "@jsx-email/all": ["@jsx-email/all@2.2.3", "", { "dependencies": { "@jsx-email/body": "1.0.2", "@jsx-email/button": "1.0.4", "@jsx-email/column": "1.0.3", "@jsx-email/container": "1.0.2", "@jsx-email/font": "1.0.3", "@jsx-email/head": "1.0.2", "@jsx-email/heading": "1.0.2", "@jsx-email/hr": "1.0.2", "@jsx-email/html": "1.0.2", "@jsx-email/img": "1.0.2", "@jsx-email/link": "1.0.2", "@jsx-email/markdown": "2.0.4", "@jsx-email/preview": "1.0.2", "@jsx-email/render": "1.1.1", "@jsx-email/row": "1.0.2", "@jsx-email/section": "1.0.2", "@jsx-email/tailwind": "2.4.4", "@jsx-email/text": "1.0.2" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-OBvLe/hVSQc0LlMSTJnkjFoqs3bmxcC4zpy/5pT5agPCSKMvAKQjzmsc2xJ2wO73jSpRV1K/g38GmvdCfrhSoQ=="],
@@ -2098,8 +2064,6 @@
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"], "@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/merman": ["@opencode-ai/merman@workspace:packages/merman"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"], "@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -3334,8 +3298,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abort-controller-x": ["abort-controller-x@0.5.0", "", {}, "sha512-yTt9CI0x+nRfX6BFMenEGP8ooPvErGH6AbFz20C2IeOLIlDsrw/VHpgne3GsCEuTA410IiFiaLVFKmgM4bKEPQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -3576,10 +3538,6 @@
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
"cbor-extract": ["cbor-extract@2.2.2", "", { "dependencies": { "node-gyp-build-optional-packages": "5.1.1" }, "optionalDependencies": { "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", "@cbor-extract/cbor-extract-linux-arm": "2.2.2", "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", "@cbor-extract/cbor-extract-linux-x64": "2.2.2", "@cbor-extract/cbor-extract-win32-x64": "2.2.2" }, "bin": { "download-cbor-prebuilds": "bin/download-prebuilds.js" } }, "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng=="],
"cbor-x": ["cbor-x@1.6.5", "", { "optionalDependencies": { "cbor-extract": "^2.2.2" } }, "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
@@ -4612,8 +4570,6 @@
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
@@ -4840,8 +4796,6 @@
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"modal": ["modal@0.9.0", "", { "dependencies": { "cbor-x": "^1.6.0", "long": "^5.3.1", "nice-grpc": "^2.1.12", "protobufjs": "^7.5.0", "smol-toml": "^1.3.3", "uuid": "^11.1.0" } }, "sha512-kCXcdJkhbJorf/q/6T9Wdlg6in9JmRnCNQnV6rVBMyeqNV/iXI6BYk4IzY4cvZ6dbauNeDMjk/Q08cbxvoIaXg=="],
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="], "morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
"motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="], "motion": ["motion@12.34.5", "", { "dependencies": { "framer-motion": "^12.34.5", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ=="],
@@ -4880,10 +4834,6 @@
"nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="], "nf3": ["nf3@0.1.12", "", {}, "sha512-qbMXT7RTGh74MYWPeqTIED8nDW70NXOULVHpdWcdZ7IVHVnAsMV9fNugSNnvooipDc1FMOzpis7T9nXJEbJhvQ=="],
"nice-grpc": ["nice-grpc@2.1.16", "", { "dependencies": { "@grpc/grpc-js": "^1.14.0", "abort-controller-x": "^0.5.0", "nice-grpc-common": "^2.0.3" } }, "sha512-Cl3Pn00212Hl8/U6bpgMxmhZj5lyv3nWoJov4cd3FjWarktrMHP4DNvSjCnDwkMWYx4W1tyscEia4JX6Y4GVCQ=="],
"nice-grpc-common": ["nice-grpc-common@2.0.3", "", { "dependencies": { "ts-error": "^1.0.6" } }, "sha512-MEhnD3JMah0mgyivpb9hpRDbOBuXBxI/TVO+OK1h6rC97WM42HsPMR+zzRNQ0C5BqYJTw1nyWiQRD0DucO+pjQ=="],
"nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="], "nitro": ["nitro@3.0.1-alpha.1", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.1", "db0": "^0.3.4", "h3": "2.0.1-rc.5", "jiti": "^2.6.1", "nf3": "^0.1.10", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.96.0", "oxc-transform": "^0.96.0", "srvx": "^0.9.5", "undici": "^7.16.0", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.4" }, "peerDependencies": { "rolldown": "*", "rollup": "^4", "vite": "^7", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-U4AxIsXxdkxzkFrK0XAw0e5Qbojk8jQ50MjjRBtBakC4HurTtQoiZvF+lSe382jhuQZCfAyywGWOFa9QzXLFaw=="],
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
@@ -5682,8 +5632,6 @@
"ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="],
"ts-error": ["ts-error@1.0.6", "", {}, "sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -6642,8 +6590,6 @@
"builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"cbor-extract/node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.1.1", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-test": "build-test.js", "node-gyp-build-optional-packages-optional": "optional.js" } }, "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -6788,8 +6734,6 @@
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"modal/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="],
"motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], "motion/framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], "nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
+3 -4
View File
@@ -368,12 +368,11 @@ Other provider exports listed above remain direct facades until they explicitly
## Provider options & HTTP overlays ## Provider options & HTTP overlays
Request options in order of stability: Three escape hatches in order of stability:
1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop). 1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop).
2. **`promptCacheKey`** — stable cache affinity lowered by every protocol that supports it. 2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
3. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `store`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing). 3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
4. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
Route/provider defaults are overridden by request-level values for each axis. Route/provider defaults are overridden by request-level values for each axis.
+5 -4
View File
@@ -33,10 +33,9 @@ const model = OpenAI.configure({
// //
// - `generation`: common controls such as max tokens, temperature, topP/topK, // - `generation`: common controls such as max tokens, temperature, topP/topK,
// penalties, seed, and stop sequences. // penalties, seed, and stop sequences.
// - `promptCacheKey`: stable cache affinity for protocols that support it.
// - `providerOptions`: namespaced provider-native behavior. For example, // - `providerOptions`: namespaced provider-native behavior. For example,
// OpenAI store behavior, Anthropic thinking, Gemini thinking config, or // OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking
// OpenRouter routing/reasoning. // config, or OpenRouter routing/reasoning.
// - `http`: last-resort serializable overlays for final request body, headers, // - `http`: last-resort serializable overlays for final request body, headers,
// and query params. Prefer typed `providerOptions` when a field is stable. // and query params. Prefer typed `providerOptions` when a field is stable.
// //
@@ -46,7 +45,9 @@ const request = LLM.request({
system: "You are concise and practical.", system: "You are concise and practical.",
prompt: "Tell me a joke", prompt: "Tell me a joke",
generation: { maxTokens: 80, temperature: 0.7 }, generation: { maxTokens: 80, temperature: 0.7 },
promptCacheKey: "tutorial-joke", providerOptions: {
openai: { promptCacheKey: "tutorial-joke" },
},
}) })
// 3. `generate` sends the request and collects the event stream into one // 3. `generate` sends the request and collects the event stream into one
+1 -2
View File
@@ -133,8 +133,7 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => { export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
return request
const policy = resolve(request.cache) const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request if (!policy.tools && !policy.system && !policy.messages) return request
+126 -65
View File
@@ -90,10 +90,15 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
]) ])
export const InputItem = Schema.Union([ export const InputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }), Schema.Struct({
role: Schema.tag("user"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesInputContent),
}),
Schema.Struct({ Schema.Struct({
role: Schema.tag("assistant"), role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(OpenResponsesOutputText), content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase), phase: Schema.optionalKey(MessagePhase),
}), }),
@@ -101,19 +106,23 @@ export const InputItem = Schema.Union([
OpenResponsesItemReference, OpenResponsesItemReference,
Schema.Struct({ Schema.Struct({
type: Schema.tag("function_call"), type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String, call_id: Schema.String,
name: Schema.String, name: Schema.String,
arguments: Schema.String, arguments: Schema.String,
}), }),
Schema.Struct({ Schema.Struct({
type: Schema.tag("function_call_output"), type: Schema.tag("function_call_output"),
id: Schema.optionalKey(Schema.String),
call_id: Schema.String, call_id: Schema.String,
output: OpenResponsesFunctionCallOutput, output: OpenResponsesFunctionCallOutput,
}), }),
]) ])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem> type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
type LoweredInputItem = type LoweredInputItem =
| OpenResponsesInputItem | OpenResponsesInputItem
| ProviderInputItem
| { | {
readonly role: "assistant" readonly role: "assistant"
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }> readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
@@ -128,7 +137,7 @@ type OpenResponsesReasoningInput = {
summary: Array<{ type: "summary_text"; text: string }> summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null encrypted_content?: string | null
} }
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
export const Tool = Schema.Struct({ export const Tool = Schema.Struct({
type: Schema.tag("function"), type: Schema.tag("function"),
@@ -254,6 +263,11 @@ export interface Extension {
readonly request: LLMRequest readonly request: LLMRequest
}) => MediaInput | undefined }) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
readonly lowerProviderItem?: (
part: ToolResultPart,
providerMetadataKey: string,
store: boolean | undefined,
) => ProviderInputItem | undefined
} }
const BASE: Extension = { id: ADAPTER, name: NAME } const BASE: Extension = { id: ADAPTER, name: NAME }
@@ -310,36 +324,48 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }), tool: (toolName) => ({ type: "function" as const, name: toolName }),
}) })
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({ const metadataItemID = (
type: "function_call", part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
call_id: part.id, providerMetadataKey: string,
name: part.name, ) => {
arguments: ProviderShared.encodeJson(part.input), if (part.itemId) return part.itemId
})
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
return undefined
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: metadata.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey] const metadata = part.providerMetadata?.[providerMetadataKey]
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0 return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
? metadata.itemId ? metadata.itemId
: undefined : undefined
} }
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const itemId = metadataItemID(part, providerMetadataKey)
return {
type: "function_call",
...(itemId === undefined ? {} : { id: itemId }),
call_id: part.id,
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
const itemId = metadataItemID(part, providerMetadataKey)
if (!itemId) return undefined
const encryptedContent =
ProviderShared.isRecord(metadata) &&
(typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
? metadata.reasoningEncryptedContent
: undefined
return {
type: "reasoning",
id: itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
metadataItemID(part, providerMetadataKey)
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* ( const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart, part: MediaPart,
request: LLMRequest, request: LLMRequest,
@@ -397,17 +423,18 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
}) })
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) { const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const options = OpenResponsesOptions.resolve(request)
const system: LoweredInputItem[] = const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system] const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store const store = options.store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses" const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) { for (const message of request.messages) {
if (message.role === "system") { if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message) const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
const previous = input.at(-1) const previous = input.at(-1)
if (previous && "role" in previous && previous.role === "user") if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
input[input.length - 1] = { input[input.length - 1] = {
role: "user", role: "user",
content: [...previous.content, { type: "input_text", text: part.text }], content: [...previous.content, { type: "input_text", text: part.text }],
@@ -427,24 +454,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") { if (message.role === "assistant") {
const content: TextPart[] = [] const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {} const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
const reasoningReferences = new Set<string>() const hostedToolItems = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => { const flushText = () => {
if (content.length === 0) return if (content.length === 0) return
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>( const groups = content.reduce<
(groups, part) => { Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
const metadata = part.providerMetadata?.[providerMetadataKey] >((groups, part) => {
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined const metadata = part.providerMetadata?.[providerMetadataKey]
const group = groups.at(-1) const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
if (group && group.phase === phase) group.parts.push(part) const itemId = metadataItemID(part, providerMetadataKey)
else groups.push({ phase, parts: [part] }) const group = groups.at(-1)
return groups if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
}, else groups.push({ phase, itemId, parts: [part] })
[], return groups
) }, [])
input.push( input.push(
...groups.map((group) => ({ ...groups.map((group) => ({
role: "assistant" as const, role: "assistant" as const,
...(group.itemId === undefined ? {} : { id: group.itemId }),
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })), content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }), ...(group.phase === undefined ? {} : { phase: group.phase }),
})), })),
@@ -460,11 +487,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText() flushText()
const reasoning = lowerReasoning(part, providerMetadataKey) const reasoning = lowerReasoning(part, providerMetadataKey)
if (!reasoning) continue 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 = reasoningItems[reasoning.id]
if (existing) { if (existing) {
existing.summary.push(...reasoning.summary) existing.summary.push(...reasoning.summary)
@@ -474,6 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
} }
const replay = { const replay = {
type: reasoning.type, type: reasoning.type,
id: reasoning.id,
summary: reasoning.summary, summary: reasoning.summary,
encrypted_content: reasoning.encrypted_content, encrypted_content: reasoning.encrypted_content,
} }
@@ -484,22 +507,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-call") { if (part.type === "tool-call") {
flushText() flushText()
if (part.providerExecuted === true) continue if (part.providerExecuted === true) continue
input.push(lowerToolCall(part)) input.push(lowerToolCall(part, providerMetadataKey))
continue continue
} }
if (part.type === "tool-result" && part.providerExecuted === true) { if (part.type === "tool-result" && part.providerExecuted === true) {
flushText() flushText()
const itemID = hostedToolItemID(part, providerMetadataKey) const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID)) const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
if (providerItem && itemID && !hostedToolItems.has(itemID)) input.push(providerItem)
if (!providerItem && store !== false && itemID && !hostedToolItems.has(itemID))
input.push({ type: "item_reference", id: itemID }) input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") { if (!providerItem && store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value const content: ReadonlyArray<Content> = part.result.value
input.push({ input.push({
role: "user", role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)), content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
}) })
} }
if (itemID) hostedToolReferences.add(itemID) if (itemID) hostedToolItems.add(itemID)
continue continue
} }
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [ return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -539,7 +564,7 @@ const lowerOptions = (request: LLMRequest) => {
return { return {
...(options.instructions ? { instructions: options.instructions } : {}), ...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}), ...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}), ...(options.promptCacheKey ? { prompt_cache_key: options.promptCacheKey } : {}),
...(options.include ? { include: options.include } : {}), ...(options.include ? { include: options.include } : {}),
...(options.reasoningEffort || options.reasoningSummary ...(options.reasoningEffort || options.reasoningSummary
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } } ? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
@@ -641,9 +666,9 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta) return [state, NO_EVENTS] if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const phase = state.messagePhases[id] const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase }) const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata) const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events] return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
} }
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => { const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -652,7 +677,13 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return onOutputTextDelta(state, { ...event, delta: event.text }, id) return onOutputTextDelta(state, { ...event, delta: event.text }, id)
} }
const events: LLMEvent[] = [] const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events] return [
{
...state,
lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id }), id),
},
events,
]
} }
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => { export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
@@ -663,7 +694,14 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
return [ return [
{ {
...state, ...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta), lifecycle: Lifecycle.reasoningDelta(
state.lifecycle,
events,
id,
event.delta,
providerMetadata(state, { itemId: itemID }),
itemID,
),
}, },
events, events,
] ]
@@ -705,7 +743,13 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
return [ return [
{ {
...state, ...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)), lifecycle: Lifecycle.reasoningStart(
state.lifecycle,
events,
`${item.id}:0`,
reasoningMetadata(state, item),
item.id,
),
reasoningItems: { reasoningItems: {
...state.reasoningItems, ...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } }, [item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
@@ -724,6 +768,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle, lifecycle,
tools: ToolStream.start(state.tools, item.id, { tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id, id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "", name: item.name ?? "",
input: item.arguments ?? "", input: item.arguments ?? "",
providerMetadata: metadata, providerMetadata: metadata,
@@ -731,7 +776,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
}, },
[ [
...events, ...events,
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }), LLMEvent.toolInputStart({
id: item.call_id ?? item.id,
itemId: item.id,
name: item.name ?? "",
providerMetadata: metadata,
}),
], ],
] ]
} }
@@ -750,6 +800,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events, events,
`${event.item_id}:0`, `${event.item_id}:0`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }), providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
event.item_id,
), ),
reasoningItems: { reasoningItems: {
...state.reasoningItems, ...state.reasoningItems,
@@ -770,6 +821,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events, events,
`${event.item_id}:${entry[0]}`, `${event.item_id}:${entry[0]}`,
providerMetadata(state, { itemId: event.item_id }), providerMetadata(state, { itemId: event.item_id }),
event.item_id,
), ),
state.lifecycle, state.lifecycle,
) )
@@ -781,6 +833,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
events, events,
`${event.item_id}:${event.summary_index}`, `${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }), providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
event.item_id,
), ),
reasoningItems: { reasoningItems: {
...state.reasoningItems, ...state.reasoningItems,
@@ -816,6 +869,7 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
events, events,
`${event.item_id}:${event.summary_index}`, `${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }), providerMetadata(state, { itemId: event.item_id }),
event.item_id,
) )
: state.lifecycle, : state.lifecycle,
reasoningItems: { reasoningItems: {
@@ -870,7 +924,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state.lifecycle, state.lifecycle,
events, events,
item.id, item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }), providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
item.id,
), ),
messageItems, messageItems,
messagePhases, messagePhases,
@@ -881,9 +936,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") { if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const tools = state.tools[item.id] const tools = state.tools[item.id]
? state.tools ? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name }) : ToolStream.start(state.tools, item.id, {
id: item.call_id,
itemId: item.id,
name: item.name,
providerMetadata: metadata,
})
const result = const result =
item.arguments === undefined item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id) ? yield* ToolStream.finish(state.id, tools, item.id)
@@ -913,7 +974,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const lifecycle = Object.entries(reasoningItem.summaryParts) const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude") .filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce( .reduce(
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata), (lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata, item.id),
state.lifecycle, state.lifecycle,
) )
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
@@ -921,12 +982,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
} }
if (!state.lifecycle.reasoning.has(item.id)) { if (!state.lifecycle.reasoning.has(item.id)) {
const lifecycle = Lifecycle.stepStart(state.lifecycle, events) const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata })) events.push(LLMEvent.reasoningStart({ id: item.id, itemId: item.id, providerMetadata: metadata }))
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata })) events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: item.id, providerMetadata: metadata }))
return [{ ...state, lifecycle }, events] satisfies StepResult return [{ ...state, lifecycle }, events] satisfies StepResult
} }
return [ return [
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) }, { ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
events, events,
] satisfies StepResult ] satisfies StepResult
} }
+17 -20
View File
@@ -132,7 +132,6 @@ export const bodyFields = {
stream: Schema.Literal(true), stream: Schema.Literal(true),
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })), stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean), store: Schema.optional(Schema.Boolean),
prompt_cache_key: Schema.optional(Schema.String),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number), max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number), max_tokens: Schema.optional(Schema.Number),
@@ -371,13 +370,12 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
return text return text
})() })()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined) const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined)
const result = { const result = {
role: "assistant" as const, role: "assistant" as const,
content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "", content: content.length === 0 ? null : ProviderShared.joinText(content),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
...(details !== undefined ? { reasoning_details: details } : {}), reasoning_details: details,
...(cacheControl !== undefined ? { cache_control: cacheControl } : {}), cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
} }
if (field === undefined || reasoningText === undefined) return result if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText } return { ...result, [field]: reasoningText }
@@ -511,7 +509,6 @@ const lowerOptions = (request: LLMRequest) => {
const options = OpenAIOptions.resolve(request) const options = OpenAIOptions.resolve(request)
return { return {
...(options.store !== undefined ? { store: options.store } : {}), ...(options.store !== undefined ? { store: options.store } : {}),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}), ...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
} }
} }
@@ -710,20 +707,23 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
Boolean(delta?.content) || Boolean(delta?.content) ||
reasoning !== undefined || reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) || (Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments)) toolDeltas.some(
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
)
if (state.finishReason !== undefined) { if (state.finishReason !== undefined) {
if (hasLateContent) if (hasLateContent)
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason") return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
return [{ ...state, usage }, events] as const return [{ ...state, usage }, events] as const
} }
const reasoningField = state.reasoningField ?? reasoning?.field const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta) if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
const deltaMetadata = reasoningMetadata(reasoningField) const deltaMetadata = reasoningMetadata(reasoningField)
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata) if (!state.lifecycle.text.has("text-0") && text !== undefined)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
else if ( else if (
reasoningDetailsObserved && reasoningDetailsObserved &&
!lifecycle.reasoning.has("reasoning-0") && !lifecycle.reasoning.has("reasoning-0") &&
@@ -749,7 +749,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position) const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
const fallbackTool = tools[fallback] ?? pendingTools[fallback] const fallbackTool = tools[fallback] ?? pendingTools[fallback]
const index = const index =
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback) tool.index ?? matched ??
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
const current = tools[index] const current = tools[index]
const pending = pendingTools[index] const pending = pendingTools[index]
const id = current?.id ?? pending?.id ?? (tool.id || undefined) const id = current?.id ?? pending?.id ?? (tool.id || undefined)
@@ -812,18 +813,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => { const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const toolCallEvents = const hasToolCalls = state.toolCallEvents.length > 0
state.finishReason === undefined && Object.keys(state.tools).length > 0
? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events
: state.toolCallEvents
const hasToolCalls = toolCallEvents.length > 0
const reason = state.finishReason const reason = state.finishReason
? { ? {
...state.finishReason, ...state.finishReason,
normalized: normalized:
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized, state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
} }
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) } : undefined
const metadata = reasoningMetadata( const metadata = reasoningMetadata(
state.reasoningField, state.reasoningField,
state.reasoningDetailsObserved ? state.reasoningDetails : undefined, state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
@@ -833,9 +830,9 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField)) ? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
: state.lifecycle : state.lifecycle
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata) const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
events.push(...toolCallEvents) events.push(...state.toolCallEvents)
Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
return events return events
} }
+32 -3
View File
@@ -38,10 +38,14 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesInputItem = Schema.Union([ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ Schema.Struct({
role: Schema.tag("assistant"), role: Schema.tag("assistant"),
id: Schema.optionalKey(Schema.String),
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })), content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)), phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
}), }),
OpenResponses.InputItem, OpenResponses.InputItem,
Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
Schema.Record(Schema.String, Schema.Unknown),
]),
]) ])
const OpenAIResponsesCoreFields = { const OpenAIResponsesCoreFields = {
@@ -80,6 +84,25 @@ const extension = {
mime_type: media.mime, mime_type: media.mime,
} }
}, },
lowerProviderItem: (part, providerMetadataKey, store) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || !ProviderShared.isRecord(metadata.item)) return undefined
if (typeof metadata.item.type !== "string") return undefined
const id = typeof metadata.item.id === "string" ? metadata.item.id : undefined
// The public API requires stored state to replay image-generation items. In
// stateless mode, lower the generated file through the existing user-image fallback.
if (metadata.item.type === "image_generation_call" && store === false) return undefined
if (metadata.item.type === "image_generation_call")
return {
type: metadata.item.type,
...(id === undefined ? {} : { id }),
...(typeof metadata.item.status === "string" ? { status: metadata.item.status } : {}),
...(typeof metadata.item.revised_prompt === "string" ? { revised_prompt: metadata.item.revised_prompt } : {}),
...(typeof metadata.item.result === "string" ? { result: metadata.item.result } : {}),
}
const item: Record<string, unknown> & { type: string } = { ...metadata.item, type: metadata.item.type }
return item
},
} satisfies OpenResponses.Extension } satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => { const nativeImageToolInput = (tool: ToolDefinition) => {
@@ -195,23 +218,29 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
item: HostedToolItem, item: HostedToolItem,
) { ) {
const tool = HOSTED_TOOLS[item.type] const tool = HOSTED_TOOLS[item.type]
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id }) const callMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const resultMetadata = OpenResponses.providerMetadata(
state,
item.type === "image_generation_call" ? { itemId: item.id } : { itemId: item.id, item },
)
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events) const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push( events.push(
LLMEvent.toolCall({ LLMEvent.toolCall({
id: item.id, id: item.id,
itemId: item.id,
name: tool.name, name: tool.name,
input: tool.input(item), input: tool.input(item),
providerExecuted: true, providerExecuted: true,
providerMetadata, providerMetadata: callMetadata,
}), }),
LLMEvent.toolResult({ LLMEvent.toolResult({
id: item.id, id: item.id,
itemId: item.id,
name: tool.name, name: tool.name,
result: yield* hostedToolResult(item), result: yield* hostedToolResult(item),
providerExecuted: true, providerExecuted: true,
providerMetadata, providerMetadata: resultMetadata,
}), }),
) )
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
+40 -12
View File
@@ -1,4 +1,10 @@
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema" import {
LLMEvent,
type FinishReasonDetails,
type ProviderMetadata,
type ResponseItemID,
type Usage,
} from "../../schema"
export interface State { export interface State {
readonly stepStarted: boolean readonly stepStarted: boolean
@@ -14,16 +20,29 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
return { ...state, stepStarted: true } return { ...state, stepStarted: true }
} }
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { export const textStart = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (state.text.has(id)) return state if (state.text.has(id)) return state
const stepped = stepStart(state, events) const stepped = stepStart(state, events)
events.push(LLMEvent.textStart({ id, providerMetadata })) events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
return { ...stepped, text: new Set([...stepped.text, id]) } return { ...stepped, text: new Set([...stepped.text, id]) }
} }
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { export const textDelta = (
const started = textStart(state, events, id) state: State,
events.push(LLMEvent.textDelta({ id, text })) events: LLMEvent[],
id: string,
text: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
const started = textStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.textDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
return started return started
} }
@@ -32,10 +51,11 @@ export const reasoningStart = (
events: LLMEvent[], events: LLMEvent[],
id: string, id: string,
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => { ): State => {
if (state.reasoning.has(id)) return state if (state.reasoning.has(id)) return state
const stepped = stepStart(state, events) const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningStart({ id, providerMetadata })) events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) } return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
} }
@@ -45,9 +65,10 @@ export const reasoningDelta = (
id: string, id: string,
text: string, text: string,
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => { ): State => {
const started = reasoningStart(state, events, id, providerMetadata) const started = reasoningStart(state, events, id, providerMetadata, itemId)
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata })) events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
return started return started
} }
@@ -56,19 +77,26 @@ export const reasoningEnd = (
events: LLMEvent[], events: LLMEvent[],
id: string, id: string,
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => { ): State => {
if (!state.reasoning.has(id)) return state if (!state.reasoning.has(id)) return state
const stepped = stepStart(state, events) const stepped = stepStart(state, events)
events.push(LLMEvent.reasoningEnd({ id, providerMetadata })) events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
const reasoning = new Set(stepped.reasoning) const reasoning = new Set(stepped.reasoning)
reasoning.delete(id) reasoning.delete(id)
return { ...stepped, reasoning } return { ...stepped, reasoning }
} }
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { export const textEnd = (
state: State,
events: LLMEvent[],
id: string,
providerMetadata?: ProviderMetadata,
itemId?: ResponseItemID,
): State => {
if (!state.text.has(id)) return state if (!state.text.has(id)) return state
const stepped = stepStart(state, events) const stepped = stepStart(state, events)
events.push(LLMEvent.textEnd({ id, providerMetadata })) events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
const text = new Set(stepped.text) const text = new Set(stepped.text)
text.delete(id) text.delete(id)
return { ...stepped, text } return { ...stepped, text }
@@ -33,6 +33,7 @@ export const ServiceTierSchema = Schema.Literals(ServiceTiers)
export interface Resolved { export interface Resolved {
readonly instructions?: string readonly instructions?: string
readonly store?: boolean readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: string readonly reasoningEffort?: string
readonly reasoningSummary?: "auto" | "concise" | "detailed" readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable> readonly include?: ReadonlyArray<ResponseIncludable>
@@ -49,6 +50,7 @@ export const resolve = (request: LLMRequest): Resolved => {
return { return {
instructions: typeof input?.instructions === "string" ? input.instructions : undefined, instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
store: typeof input?.store === "boolean" ? input.store : undefined, store: typeof input?.store === "boolean" ? input.store : undefined,
promptCacheKey: typeof input?.promptCacheKey === "string" ? input.promptCacheKey : undefined,
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined, reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
reasoningSummary: reasoningSummary:
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed" reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
+23 -2
View File
@@ -1,5 +1,12 @@
import { Effect } from "effect" import { Effect } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema" import {
AIError,
LLMEvent,
type ProviderMetadata,
type ResponseItemID,
type ToolCall,
type ToolInputError,
} from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared" import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number type StreamKey = string | number
@@ -10,6 +17,7 @@ type StreamKey = string | number
* so far, not the parsed object. * so far, not the parsed object.
*/ */
export interface PendingTool extends ToolAccumulator { export interface PendingTool extends ToolAccumulator {
readonly itemId?: ResponseItemID
readonly providerExecuted?: boolean readonly providerExecuted?: boolean
readonly providerMetadata?: ProviderMetadata readonly providerMetadata?: ProviderMetadata
} }
@@ -52,6 +60,7 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
const inputStart = (tool: PendingTool) => const inputStart = (tool: PendingTool) =>
LLMEvent.toolInputStart({ LLMEvent.toolInputStart({
id: tool.id, id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name, name: tool.name,
providerExecuted: tool.providerExecuted ? true : undefined, providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata, providerMetadata: tool.providerMetadata,
@@ -60,6 +69,7 @@ const inputStart = (tool: PendingTool) =>
const inputDelta = (tool: PendingTool, text: string) => const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({ LLMEvent.toolInputDelta({
id: tool.id, id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name, name: tool.name,
text, text,
}) })
@@ -70,6 +80,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
Effect.map((input): ToolCall | ToolInputError => Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({ LLMEvent.toolCall({
id: tool.id, id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name, name: tool.name,
input, input,
providerExecuted: tool.providerExecuted ? true : undefined, providerExecuted: tool.providerExecuted ? true : undefined,
@@ -82,6 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
: Effect.succeed( : Effect.succeed(
LLMEvent.toolInputError({ LLMEvent.toolInputError({
id: tool.id, id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name, name: tool.name,
raw, raw,
}), }),
@@ -93,7 +105,15 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> => const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error" event.type === "tool-input-error"
? [event] ? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event] : [
LLMEvent.toolInputEnd({
id: tool.id,
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
name: tool.name,
providerMetadata: tool.providerMetadata,
}),
event,
]
/** Store the updated tool and produce the optional public delta event. */ /** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>( const appendTool = <K extends StreamKey>(
@@ -148,6 +168,7 @@ export const appendOrStart = <K extends StreamKey>(
id, id,
name, name,
input: `${current?.input ?? ""}${delta.text}`, input: `${current?.input ?? ""}${delta.text}`,
itemId: current?.itemId,
providerExecuted: current?.providerExecuted, providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata, providerMetadata: current?.providerMetadata,
} }
@@ -5,6 +5,7 @@ export interface OpenResponsesOptionsInput {
readonly [key: string]: unknown readonly [key: string]: unknown
readonly instructions?: string readonly instructions?: string
readonly store?: boolean readonly store?: boolean
readonly promptCacheKey?: string
readonly reasoningEffort?: ReasoningEffort readonly reasoningEffort?: ReasoningEffort
readonly reasoningSummary?: "auto" | "concise" | "detailed" readonly reasoningSummary?: "auto" | "concise" | "detailed"
readonly include?: ReadonlyArray<ResponseIncludable> readonly include?: ReadonlyArray<ResponseIncludable>
@@ -17,6 +17,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
const openai = Object.fromEntries( const openai = Object.fromEntries(
definedEntries({ definedEntries({
store: options?.store, store: options?.store,
promptCacheKey: options?.promptCacheKey,
reasoningEffort: options?.reasoningEffort, reasoningEffort: options?.reasoningEffort,
reasoningSummary: options?.reasoningSummary, reasoningSummary: options?.reasoningSummary,
include: options?.include, include: options?.include,
+2 -1
View File
@@ -55,6 +55,7 @@ export interface OpenRouterOptions {
readonly debug?: Readonly<{ echo_upstream_body?: boolean }> readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly models?: ReadonlyArray<string> readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin> readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{ readonly reasoning?: Readonly<{
enabled?: boolean enabled?: boolean
@@ -121,7 +122,6 @@ export const protocol = Protocol.make({
...body, ...body,
messages, messages,
...bodyOptions(request.providerOptions?.openrouter), ...bodyOptions(request.providerOptions?.openrouter),
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
} as OpenRouterBody } as OpenRouterBody
}), }),
), ),
@@ -161,6 +161,7 @@ const bodyOptions = (input: unknown) => {
...(isRecord(debug) ? { debug } : {}), ...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}), ...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}), ...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
} }
} }
-2
View File
@@ -47,8 +47,6 @@ const chatRoute = Route.make({
protocol: OpenAIChat.protocol, protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }), endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
transport: OpenAICompatibleChat.route.transport, transport: OpenAICompatibleChat.route.transport,
headers: ({ request }): Record<string, string> =>
request.promptCacheKey ? { "x-grok-conv-id": request.promptCacheKey } : {},
}) })
export const routes = [responsesRoute, chatRoute] export const routes = [responsesRoute, chatRoute]
+7 -1
View File
@@ -255,7 +255,13 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
return Effect.succeed(event) return Effect.succeed(event)
}), }),
Stream.onEnd(Effect.suspend(() => (terminal ? Effect.void : Effect.fail(incompleteStreamError(route))))), Stream.onEnd(
Effect.suspend(() =>
terminal
? Effect.void
: Effect.fail(incompleteStreamError(route)),
),
),
) )
}) })
+2 -14
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema } from "effect" import { Cause, Context, Effect, Layer } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
Headers, Headers,
@@ -198,20 +198,8 @@ const responseBody = (body: string | void, request: HttpClientRequest.HttpClient
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true } return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
} }
const decodeProviderBody = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
message: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
}),
),
)
const providerMessage = (status: number, body: { readonly body?: string }) => { const providerMessage = (status: number, body: { readonly body?: string }) => {
if (body.body && body.body.length <= 500) { if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}`
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
}
return `Provider request failed with HTTP ${status}` return `Provider request failed with HTTP ${status}`
} }
+72 -24
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids" import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, ToolCallID } from "./ids"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
import { ProviderFailureClassification } from "./errors" import { ProviderFailureClassification } from "./errors"
@@ -84,6 +84,7 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
export const TextStart = Schema.Struct({ export const TextStart = Schema.Struct({
type: Schema.tag("text-start"), type: Schema.tag("text-start"),
id: ContentBlockID, id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextStart" }) }).annotate({ identifier: "LLM.Event.TextStart" })
export type TextStart = Schema.Schema.Type<typeof TextStart> export type TextStart = Schema.Schema.Type<typeof TextStart>
@@ -92,6 +93,7 @@ export const TextDelta = Schema.Struct({
type: Schema.tag("text-delta"), type: Schema.tag("text-delta"),
id: ContentBlockID, id: ContentBlockID,
text: Schema.String, text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextDelta" }) }).annotate({ identifier: "LLM.Event.TextDelta" })
export type TextDelta = Schema.Schema.Type<typeof TextDelta> export type TextDelta = Schema.Schema.Type<typeof TextDelta>
@@ -99,6 +101,7 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
export const TextEnd = Schema.Struct({ export const TextEnd = Schema.Struct({
type: Schema.tag("text-end"), type: Schema.tag("text-end"),
id: ContentBlockID, id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.TextEnd" }) }).annotate({ identifier: "LLM.Event.TextEnd" })
export type TextEnd = Schema.Schema.Type<typeof TextEnd> export type TextEnd = Schema.Schema.Type<typeof TextEnd>
@@ -106,6 +109,7 @@ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
export const ReasoningStart = Schema.Struct({ export const ReasoningStart = Schema.Struct({
type: Schema.tag("reasoning-start"), type: Schema.tag("reasoning-start"),
id: ContentBlockID, id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningStart" }) }).annotate({ identifier: "LLM.Event.ReasoningStart" })
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart> export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
@@ -114,6 +118,7 @@ export const ReasoningDelta = Schema.Struct({
type: Schema.tag("reasoning-delta"), type: Schema.tag("reasoning-delta"),
id: ContentBlockID, id: ContentBlockID,
text: Schema.String, text: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningDelta" }) }).annotate({ identifier: "LLM.Event.ReasoningDelta" })
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta> export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
@@ -121,6 +126,7 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
export const ReasoningEnd = Schema.Struct({ export const ReasoningEnd = Schema.Struct({
type: Schema.tag("reasoning-end"), type: Schema.tag("reasoning-end"),
id: ContentBlockID, id: ContentBlockID,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ReasoningEnd" }) }).annotate({ identifier: "LLM.Event.ReasoningEnd" })
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd> export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
@@ -129,6 +135,7 @@ export const ToolInputStart = Schema.Struct({
type: Schema.tag("tool-input-start"), type: Schema.tag("tool-input-start"),
id: ToolCallID, id: ToolCallID,
name: Schema.String, name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputStart" }) }).annotate({ identifier: "LLM.Event.ToolInputStart" })
@@ -137,6 +144,7 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
export const ToolInputDelta = Schema.Struct({ export const ToolInputDelta = Schema.Struct({
type: Schema.tag("tool-input-delta"), type: Schema.tag("tool-input-delta"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
text: Schema.String, text: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputDelta" }) }).annotate({ identifier: "LLM.Event.ToolInputDelta" })
@@ -146,6 +154,7 @@ export const ToolInputEnd = Schema.Struct({
type: Schema.tag("tool-input-end"), type: Schema.tag("tool-input-end"),
id: ToolCallID, id: ToolCallID,
name: Schema.String, name: Schema.String,
itemId: Schema.optional(ResponseItemID),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Event.ToolInputEnd" }) }).annotate({ identifier: "LLM.Event.ToolInputEnd" })
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd> export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
@@ -154,6 +163,7 @@ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
export const ToolInputError = Schema.Struct({ export const ToolInputError = Schema.Struct({
type: Schema.tag("tool-input-error"), type: Schema.tag("tool-input-error"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
raw: Schema.String, raw: Schema.String,
}).annotate({ identifier: "LLM.Event.ToolInputError" }) }).annotate({ identifier: "LLM.Event.ToolInputError" })
@@ -162,6 +172,7 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
export const ToolCall = Schema.Struct({ export const ToolCall = Schema.Struct({
type: Schema.tag("tool-call"), type: Schema.tag("tool-call"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
input: Schema.Unknown, input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
@@ -172,6 +183,7 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
export const ToolResult = Schema.Struct({ export const ToolResult = Schema.Struct({
type: Schema.tag("tool-result"), type: Schema.tag("tool-result"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
result: ToolResultValue, result: ToolResultValue,
output: Schema.optional(ToolOutput), output: Schema.optional(ToolOutput),
@@ -183,6 +195,7 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
export const ToolError = Schema.Struct({ export const ToolError = Schema.Struct({
type: Schema.tag("tool-error"), type: Schema.tag("tool-error"),
id: ToolCallID, id: ToolCallID,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
message: Schema.String, message: Schema.String,
error: Schema.optional(Schema.Defect()), error: Schema.optional(Schema.Defect()),
@@ -334,12 +347,14 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
interface ContentAssembly { interface ContentAssembly {
readonly contentIndex: number readonly contentIndex: number
readonly text: string readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata readonly providerMetadata?: ProviderMetadata
} }
interface ToolInputAssembly { interface ToolInputAssembly {
readonly name: string readonly name: string
readonly text: string readonly text: string
readonly itemId?: ResponseItemID
readonly providerMetadata?: ProviderMetadata readonly providerMetadata?: ProviderMetadata
} }
@@ -385,11 +400,27 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
} }
} }
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => const textContent = (
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata } text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "text",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => const reasoningContent = (
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata } text: string,
itemId: ResponseItemID | undefined,
providerMetadata: ProviderMetadata | undefined,
): ContentPart => ({
type: "reasoning",
text,
...(itemId === undefined ? {} : { itemId }),
...(providerMetadata === undefined ? {} : { providerMetadata }),
})
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({ const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
...state, ...state,
@@ -404,26 +435,32 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)), state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
) )
const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { const ensureText = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
if (state.textParts[id]) return state if (state.textParts[id]) return state
return { return {
...appendContent(state, textContent("", providerMetadata)), ...appendContent(state, textContent("", itemId, providerMetadata)),
textParts: { textParts: {
...state.textParts, ...state.textParts,
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, [id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
}, },
} }
} }
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => { const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
const started = ensureText(state, event.id, event.providerMetadata) const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
const current = started.textParts[event.id] const current = started.textParts[event.id]
if (!current) return started if (!current) return started
const text = current.text + event.text const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata const providerMetadata = event.providerMetadata ?? current.providerMetadata
return { return {
...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)), ...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } }, textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
} }
} }
@@ -431,32 +468,39 @@ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
const current = state.textParts[event.id] const current = state.textParts[event.id]
if (!current) return state if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return { return {
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)), ...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } }, textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
} }
} }
const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { const ensureReasoning = (
state: ResponseState,
id: string,
itemId?: ResponseItemID,
providerMetadata?: ProviderMetadata,
): ResponseState => {
if (state.reasoningParts[id]) return state if (state.reasoningParts[id]) return state
return { return {
...appendContent(state, reasoningContent("", providerMetadata)), ...appendContent(state, reasoningContent("", itemId, providerMetadata)),
reasoningParts: { reasoningParts: {
...state.reasoningParts, ...state.reasoningParts,
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, [id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
}, },
} }
} }
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => { const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
const started = ensureReasoning(state, event.id, event.providerMetadata) const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
const current = started.reasoningParts[event.id] const current = started.reasoningParts[event.id]
if (!current) return started if (!current) return started
const text = current.text + event.text const text = current.text + event.text
const itemId = event.itemId ?? current.itemId
const providerMetadata = event.providerMetadata ?? current.providerMetadata const providerMetadata = event.providerMetadata ?? current.providerMetadata
return { return {
...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)), ...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } }, reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
} }
} }
@@ -464,9 +508,10 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
const current = state.reasoningParts[event.id] const current = state.reasoningParts[event.id]
if (!current) return state if (!current) return state
const providerMetadata = event.providerMetadata ?? current.providerMetadata const providerMetadata = event.providerMetadata ?? current.providerMetadata
const itemId = event.itemId ?? current.itemId
return { return {
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)), ...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } }, reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
} }
} }
@@ -474,7 +519,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
...state, ...state,
toolInputs: { toolInputs: {
...state.toolInputs, ...state.toolInputs,
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, [event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
}, },
}) })
@@ -495,6 +540,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
[event.id]: { [event.id]: {
...current, ...current,
name: event.name, name: event.name,
itemId: event.itemId ?? current.itemId,
providerMetadata: event.providerMetadata ?? current.providerMetadata, providerMetadata: event.providerMetadata ?? current.providerMetadata,
}, },
}, },
@@ -504,6 +550,7 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
const toolCallContent = (event: ToolCall): ContentPart => const toolCallContent = (event: ToolCall): ContentPart =>
ToolCallPart.make({ ToolCallPart.make({
id: event.id, id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name, name: event.name,
input: event.input, input: event.input,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -513,6 +560,7 @@ const toolCallContent = (event: ToolCall): ContentPart =>
const toolResultContent = (event: ToolResult): ContentPart => const toolResultContent = (event: ToolResult): ContentPart =>
ToolResultPart.make({ ToolResultPart.make({
id: event.id, id: event.id,
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
name: event.name, name: event.name,
result: event.result, result: event.result,
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
@@ -528,13 +576,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
const next = appendEvent(state, event) const next = appendEvent(state, event)
switch (event.type) { switch (event.type) {
case "text-start": case "text-start":
return ensureText(next, event.id, event.providerMetadata) return ensureText(next, event.id, event.itemId, event.providerMetadata)
case "text-delta": case "text-delta":
return reduceTextDelta(next, event) return reduceTextDelta(next, event)
case "text-end": case "text-end":
return reduceTextEnd(next, event) return reduceTextEnd(next, event)
case "reasoning-start": case "reasoning-start":
return ensureReasoning(next, event.id, event.providerMetadata) return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
case "reasoning-delta": case "reasoning-delta":
return reduceReasoningDelta(next, event) return reduceReasoningDelta(next, event)
case "reasoning-end": case "reasoning-end":
+3
View File
@@ -21,6 +21,9 @@ export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String export const ResponseID = Schema.String
export type ResponseID = Schema.Schema.Type<typeof ResponseID> export type ResponseID = Schema.Schema.Type<typeof ResponseID>
export const ResponseItemID = Schema.String
export type ResponseItemID = Schema.Schema.Type<typeof ResponseItemID>
export const ContentBlockID = Schema.String export const ContentBlockID = Schema.String
export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID> export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
+35 -32
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options" import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record" import { isRecord } from "../utils/record"
@@ -25,6 +25,7 @@ export const SystemPart = Object.assign(systemPartSchema, {
export const TextPart = Schema.Struct({ export const TextPart = Schema.Struct({
type: Schema.Literal("text"), type: Schema.Literal("text"),
text: Schema.String, text: Schema.String,
itemId: Schema.optional(ResponseItemID),
cache: Schema.optional(CacheHint), cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
@@ -45,34 +46,35 @@ const isToolResultValue = (value: unknown): value is ToolResultValue =>
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") && (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
"value" in value "value" in value
const toolResultValueSchema = Schema.Union([ export const ToolResultValue = Object.assign(
Schema.Struct({ Schema.Union([
type: Schema.Literal("json"), Schema.Struct({
value: Schema.Unknown, type: Schema.Literal("json"),
}), value: Schema.Unknown,
Schema.Struct({ }),
type: Schema.Literal("text"), Schema.Struct({
value: Schema.Unknown, type: Schema.Literal("text"),
}), value: Schema.Unknown,
Schema.Struct({ }),
type: Schema.Literal("error"), Schema.Struct({
value: Schema.Unknown, type: Schema.Literal("error"),
}), value: Schema.Unknown,
Schema.Struct({ }),
type: Schema.Literal("content"), Schema.Struct({
value: Schema.Array(Tool.Content), type: Schema.Literal("content"),
}), value: Schema.Array(Tool.Content),
]).annotate({ identifier: "LLM.ToolResult" }) }),
export type ToolResultValue = Schema.Schema.Type<typeof toolResultValueSchema> ]).annotate({ identifier: "LLM.ToolResult" }),
{
export const ToolResultValue = Object.assign(toolResultValueSchema, { is: isToolResultValue,
is: isToolResultValue, make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => {
make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => { if (isToolResultValue(value)) return value
if (isToolResultValue(value)) return value if (type === "content") return { type, value: Array.isArray(value) ? value : [] }
if (type === "content") return { type, value: Array.isArray(value) ? value : [] } return { type, value }
return { type, value } },
}, },
}) )
export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
export interface ToolOutput { export interface ToolOutput {
readonly structured: unknown readonly structured: unknown
@@ -120,6 +122,7 @@ export const ToolCallPart = Object.assign(
Schema.Struct({ Schema.Struct({
type: Schema.Literal("tool-call"), type: Schema.Literal("tool-call"),
id: Schema.String, id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
input: Schema.Unknown, input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
@@ -137,6 +140,7 @@ export const ToolResultPart = Object.assign(
Schema.Struct({ Schema.Struct({
type: Schema.Literal("tool-result"), type: Schema.Literal("tool-result"),
id: Schema.String, id: Schema.String,
itemId: Schema.optional(ResponseItemID),
name: Schema.String, name: Schema.String,
result: ToolResultValue, result: ToolResultValue,
providerExecuted: Schema.optional(Schema.Boolean), providerExecuted: Schema.optional(Schema.Boolean),
@@ -153,6 +157,7 @@ export const ToolResultPart = Object.assign(
): ToolResultPart => ({ ): ToolResultPart => ({
type: "tool-result", type: "tool-result",
id: input.id, id: input.id,
...(input.itemId === undefined ? {} : { itemId: input.itemId }),
name: input.name, name: input.name,
result: ToolResultValue.make(input.result, input.resultType), result: ToolResultValue.make(input.result, input.resultType),
providerExecuted: input.providerExecuted, providerExecuted: input.providerExecuted,
@@ -167,6 +172,7 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
export const ReasoningPart = Schema.Struct({ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"), type: Schema.Literal("reasoning"),
text: Schema.String, text: Schema.String,
itemId: Schema.optional(ResponseItemID),
encrypted: Schema.optional(Schema.String), encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint), cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
@@ -180,7 +186,7 @@ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, Tool
export type ContentPart = Schema.Schema.Type<typeof ContentPart> export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({ export class Message extends Schema.Class<Message>("LLM.Message")({
id: Schema.optional(Schema.String), id: Schema.optional(ResponseItemID),
role: MessageRole, role: MessageRole,
content: Schema.Array(ContentPart), content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
@@ -271,8 +277,6 @@ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
providerOptions: Schema.optional(ProviderOptions), providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions), http: Schema.optional(HttpOptions),
cache: Schema.optional(CachePolicy), cache: Schema.optional(CachePolicy),
// Stable cache affinity for protocols that support provider-managed prompt caching.
promptCacheKey: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {} }) {}
@@ -290,7 +294,6 @@ export namespace LLMRequest {
providerOptions: request.providerOptions, providerOptions: request.providerOptions,
http: request.http, http: request.http,
cache: request.cache, cache: request.cache,
promptCacheKey: request.promptCacheKey,
metadata: request.metadata, metadata: request.metadata,
}) })
+2 -2
View File
@@ -79,7 +79,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
id: call.id, id: call.id,
name: call.name, name: call.name,
result: settlement.result, result: settlement.result,
providerMetadata: call.providerMetadata, ...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
}), }),
] ]
: [ : [
@@ -88,7 +88,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
name: call.name, name: call.name,
result: settlement.result, result: settlement.result,
output: settlement.output, output: settlement.output,
providerMetadata: call.providerMetadata, ...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
}), }),
], ],
} }
+7 -2
View File
@@ -1,6 +1,10 @@
import { Effect, JsonSchema, Schema } from "effect" import { Effect, JsonSchema, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import type { ToolCallPart, ToolDefinition as ToolDefinitionClass, ToolOutput as ToolOutputType } from "./schema" import type {
ToolCallPart,
ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType,
} from "./schema"
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema" import { ToolDefinition, ToolFailure, ToolOutput } from "./schema"
/** /**
@@ -240,7 +244,8 @@ const project = (
): ToolOutputType => ): ToolOutputType =>
ToolOutput.make( ToolOutput.make(
toStructuredOutput?.(output) ?? output, toStructuredOutput?.(output) ?? output,
toModelOutput?.({ id, parameters, output }) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []), toModelOutput?.({ id, parameters, output }) ??
(typeof output === "string" ? [{ type: "text", text: output }] : []),
) )
export { ToolFailure } export { ToolFailure }
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache", "tool"], "tags": [
"prefix:anthropic-messages-cache",
"provider:anthropic",
"protocol:anthropic-messages",
"cache",
"tool"
],
"name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback", "name": "anthropic-messages-cache/keeps-a-long-tool-turn-inside-the-cache-lookback",
"recordedAt": "2026-07-24T16:22:29.494Z" "recordedAt": "2026-07-24T16:22:29.494Z"
}, },
@@ -1,7 +1,12 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:bedrock-converse-cache", "provider:amazon-bedrock", "protocol:bedrock-converse", "cache"], "tags": [
"prefix:bedrock-converse-cache",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"cache"
],
"name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call", "name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call",
"recordedAt": "2026-07-23T02:29:10.955Z" "recordedAt": "2026-07-23T02:29:10.955Z"
}, },
@@ -1,7 +1,11 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:google-images", "provider:google", "protocol:google-images"], "tags": [
"prefix:google-images",
"provider:google",
"protocol:google-images"
],
"name": "google-images/generates-an-image", "name": "google-images/generates-an-image",
"recordedAt": "2026-07-19T16:05:51.868Z" "recordedAt": "2026-07-19T16:05:51.868Z"
}, },
@@ -1,7 +1,11 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:openai-images", "provider:openai", "protocol:openai-images"], "tags": [
"prefix:openai-images",
"provider:openai",
"protocol:openai-images"
],
"name": "openai-images/generates-an-image", "name": "openai-images/generates-an-image",
"recordedAt": "2026-07-19T14:41:43.188Z" "recordedAt": "2026-07-19T14:41:43.188Z"
}, },
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,7 +2,12 @@
"version": 1, "version": 1,
"metadata": { "metadata": {
"model": "anthropic/claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6",
"tags": ["prefix:openai-compatible-chat", "provider:openrouter", "protocol:openai-chat", "reasoning"], "tags": [
"prefix:openai-compatible-chat",
"provider:openrouter",
"protocol:openai-chat",
"reasoning"
],
"name": "openrouter-reasoning", "name": "openrouter-reasoning",
"recordedAt": "2026-07-18T11:28:39.267Z" "recordedAt": "2026-07-18T11:28:39.267Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"tool",
"tool-result"
],
"name": "pdf/anthropic-tool-result", "name": "pdf/anthropic-tool-result",
"recordedAt": "2026-07-22T18:15:39.002Z" "recordedAt": "2026-07-22T18:15:39.002Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:anthropic", "protocol:anthropic-messages", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:anthropic",
"protocol:anthropic-messages",
"user-input"
],
"name": "pdf/anthropic-user-input", "name": "pdf/anthropic-user-input",
"recordedAt": "2026-07-22T18:15:37.979Z" "recordedAt": "2026-07-22T18:15:37.979Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"tool",
"tool-result"
],
"name": "pdf/bedrock-tool-result", "name": "pdf/bedrock-tool-result",
"recordedAt": "2026-07-22T18:15:52.400Z" "recordedAt": "2026-07-22T18:15:52.400Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:amazon-bedrock", "protocol:bedrock-converse", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:amazon-bedrock",
"protocol:bedrock-converse",
"user-input"
],
"name": "pdf/bedrock-user-input", "name": "pdf/bedrock-user-input",
"recordedAt": "2026-07-22T18:15:48.408Z" "recordedAt": "2026-07-22T18:15:48.408Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"tool",
"tool-result"
],
"name": "pdf/gemini-tool-result", "name": "pdf/gemini-tool-result",
"recordedAt": "2026-07-22T18:21:59.606Z" "recordedAt": "2026-07-22T18:21:59.606Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:google", "protocol:gemini", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:google",
"protocol:gemini",
"user-input"
],
"name": "pdf/gemini-user-input", "name": "pdf/gemini-user-input",
"recordedAt": "2026-07-22T18:20:55.140Z" "recordedAt": "2026-07-22T18:20:55.140Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"tool",
"tool-result"
],
"name": "pdf/openai-tool-result", "name": "pdf/openai-tool-result",
"recordedAt": "2026-07-22T18:15:36.438Z" "recordedAt": "2026-07-22T18:15:36.438Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:openai", "protocol:openai-responses", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:openai",
"protocol:openai-responses",
"user-input"
],
"name": "pdf/openai-user-input", "name": "pdf/openai-user-input",
"recordedAt": "2026-07-22T18:15:34.867Z" "recordedAt": "2026-07-22T18:15:34.867Z"
}, },
@@ -1,7 +1,14 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "tool", "tool-result"], "tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:openai-responses",
"tool",
"tool-result"
],
"name": "pdf/xai-tool-result", "name": "pdf/xai-tool-result",
"recordedAt": "2026-07-22T18:15:43.608Z" "recordedAt": "2026-07-22T18:15:43.608Z"
}, },
@@ -1,7 +1,13 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:pdf", "pdf", "provider:xai", "protocol:openai-responses", "user-input"], "tags": [
"prefix:pdf",
"pdf",
"provider:xai",
"protocol:openai-responses",
"user-input"
],
"name": "pdf/xai-user-input", "name": "pdf/xai-user-input",
"recordedAt": "2026-07-22T18:15:42.429Z" "recordedAt": "2026-07-22T18:15:42.429Z"
}, },
@@ -2,7 +2,12 @@
"version": 1, "version": 1,
"metadata": { "metadata": {
"model": "anthropic/claude-sonnet-4.6", "model": "anthropic/claude-sonnet-4.6",
"tags": ["prefix:openai-compatible-chat", "provider:vercel-ai-gateway", "protocol:openai-chat", "reasoning"], "tags": [
"prefix:openai-compatible-chat",
"provider:vercel-ai-gateway",
"protocol:openai-chat",
"reasoning"
],
"name": "vercel-ai-gateway-reasoning", "name": "vercel-ai-gateway-reasoning",
"recordedAt": "2026-07-18T11:28:42.077Z" "recordedAt": "2026-07-18T11:28:42.077Z"
}, },
@@ -1,7 +1,11 @@
{ {
"version": 1, "version": 1,
"metadata": { "metadata": {
"tags": ["prefix:xai-images", "provider:xai", "protocol:xai-images"], "tags": [
"prefix:xai-images",
"provider:xai",
"protocol:xai-images"
],
"name": "xai-images/generates-an-image", "name": "xai-images/generates-an-image",
"recordedAt": "2026-07-19T15:56:20.098Z" "recordedAt": "2026-07-19T15:56:20.098Z"
}, },
+14 -5
View File
@@ -8,7 +8,6 @@ import {
type ProviderMetadata, type ProviderMetadata,
type ToolCallPart, type ToolCallPart,
ToolResultPart, ToolResultPart,
type ToolResultValue,
type Usage, type Usage,
} from "../../src/schema" } from "../../src/schema"
import { type Tools, toDefinitions } from "../../src/tool" import { type Tools, toDefinitions } from "../../src/tool"
@@ -61,9 +60,10 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
...dispatched.map(([call, dispatched]) => ...dispatched.map(([call, dispatched]) =>
Message.tool({ Message.tool({
id: call.id, id: call.id,
itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
name: call.name, name: call.name,
result: dispatched.result, result: dispatched.result,
providerMetadata: call.providerMetadata, providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
}), }),
), ),
], ],
@@ -89,9 +89,15 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
for (const event of events) { for (const event of events) {
if (event.type === "text-delta" || event.type === "reasoning-delta") { if (event.type === "text-delta" || event.type === "reasoning-delta") {
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text) appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
} else if (event.type === "text-end" || event.type === "reasoning-end") { } else if (event.type === "text-end" || event.type === "reasoning-end") {
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata) appendText(
assistantContent,
event.type === "text-end" ? "text" : "reasoning",
"",
event.itemId,
event.providerMetadata,
)
} else if (event.type === "tool-call") { } else if (event.type === "tool-call") {
assistantContent.push(event) assistantContent.push(event)
if (!event.providerExecuted) toolCalls.push(event) if (!event.providerExecuted) toolCalls.push(event)
@@ -99,6 +105,7 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
assistantContent.push( assistantContent.push(
ToolResultPart.make({ ToolResultPart.make({
id: event.id, id: event.id,
itemId: event.itemId,
name: event.name, name: event.name,
result: event.result, result: event.result,
providerExecuted: true, providerExecuted: true,
@@ -118,6 +125,7 @@ const appendText = (
content: ContentPart[], content: ContentPart[],
type: "text" | "reasoning", type: "text" | "reasoning",
text: string, text: string,
itemId?: string,
providerMetadata?: ProviderMetadata, providerMetadata?: ProviderMetadata,
) => { ) => {
const last = content.at(-1) const last = content.at(-1)
@@ -125,11 +133,12 @@ const appendText = (
content[content.length - 1] = { content[content.length - 1] = {
...last, ...last,
text: `${last.text}${text}`, text: `${last.text}${text}`,
itemId: itemId ?? last.itemId,
providerMetadata: providerMetadata ?? last.providerMetadata, providerMetadata: providerMetadata ?? last.providerMetadata,
} }
return return
} }
content.push({ type, text, providerMetadata }) content.push({ type, text, itemId, providerMetadata })
} }
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => { const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
@@ -3,11 +3,11 @@ import { CloudflareWorkersAI } from "../../src/providers"
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model") const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
LLM.request({ model, prompt: "Hello", promptCacheKey: "cache" }) LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
// @ts-expect-error Prompt cache keys must be strings. // @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
promptCacheKey: 1, providerOptions: { openai: { promptCacheKey: 1 } },
}) })
@@ -519,8 +519,14 @@ describe("Bedrock Converse route", () => {
fixedBytes( fixedBytes(
eventStreamBody( eventStreamBody(
["messageStart", { role: "assistant" }], ["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }], [
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }], "contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } },
],
[
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["messageStop", { stopReason: "end_turn" }], ["messageStop", { stopReason: "end_turn" }],
), ),
), ),
@@ -555,7 +561,10 @@ describe("Bedrock Converse route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const body = eventStreamBody( const body = eventStreamBody(
["messageStart", { role: "assistant" }], ["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }], [
"contentBlockDelta",
{ contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } },
],
["contentBlockStop", { contentBlockIndex: 0 }], ["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }], ["messageStop", { stopReason: "end_turn" }],
) )
+26 -91
View File
@@ -15,8 +15,6 @@ import {
} from "../../src" } from "../../src"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as XAI from "../../src/providers/xai"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
import { ProviderShared } from "../../src/protocols/shared" import { ProviderShared } from "../../src/protocols/shared"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
@@ -102,24 +100,6 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("concatenates assistant text parts without adding separators", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Hello" },
{ type: "text", text: " world" },
]),
],
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }])
}),
)
it.effect("writes reasoning to a configured custom field on every assistant message", () => it.effect("writes reasoning to a configured custom field on every assistant message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -174,47 +154,6 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("maps the request prompt cache key", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatible.configure({
baseURL: "https://api.compatible.test/v1",
apiKey: "test",
}).model("compatible-model"),
prompt: "Hello",
promptCacheKey: "session_123",
}),
)
expect(prepared.body.prompt_cache_key).toBe("session_123")
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
LLMClient.generate(
LLM.request({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
prompt: "Hello",
promptCacheKey: "session_123",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
const body = decodeJson(yield* Effect.promise(() => web.text()))
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () => it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
@@ -596,7 +535,7 @@ describe("OpenAI Chat route", () => {
}), }),
) )
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }]) expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
}), }),
) )
@@ -845,7 +784,7 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("preserves scalar reasoning after content starts", () => it.effect("ignores scalar reasoning after content starts", () =>
Effect.gen(function* () { Effect.gen(function* () {
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }] const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
const response = yield* LLMClient.generate(request).pipe( const response = yield* LLMClient.generate(request).pipe(
@@ -861,11 +800,11 @@ describe("OpenAI Chat route", () => {
), ),
) )
expect(response.reasoning).toBe("detailscalar") expect(response.reasoning).toBe("detail")
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2) expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2) expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({ expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
openai: { reasoningField: "reasoning", reasoningDetails: details }, openai: { reasoningDetails: details },
}) })
}), }),
) )
@@ -965,7 +904,7 @@ describe("OpenAI Chat route", () => {
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1) expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }]) expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
}), }),
) )
@@ -1015,7 +954,7 @@ describe("OpenAI Chat route", () => {
) )
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] }, { role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
]) ])
}), }),
) )
@@ -1040,7 +979,7 @@ describe("OpenAI Chat route", () => {
) )
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] }, { role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
]) ])
}), }),
) )
@@ -1062,7 +1001,7 @@ describe("OpenAI Chat route", () => {
) )
expect(replay.body.messages).toEqual([ expect(replay.body.messages).toEqual([
{ role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details }, { role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
]) ])
}), }),
) )
@@ -1170,7 +1109,7 @@ describe("OpenAI Chat route", () => {
}), }),
) )
it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () => it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( const body = sseEvents(
deltaChunk({ deltaChunk({
@@ -1182,31 +1121,27 @@ describe("OpenAI Chat route", () => {
const input = LLMRequest.update(request, { const input = LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}) })
const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body))) const events: LLMEvent[] = []
const streamError = yield* LLMClient.stream(input).pipe(
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
Effect.flip,
Effect.provide(fixedResponse(body)),
)
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(response.events).toEqual([ expect(events).toEqual([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: undefined,
},
{
type: "step-finish",
index: 0,
reason: { normalized: "tool-calls" },
usage: undefined,
providerMetadata: undefined,
},
{ type: "finish", reason: { normalized: "tool-calls" }, usage: undefined },
]) ])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
}), }),
) )
@@ -300,9 +300,7 @@ describe("OpenAI-compatible Chat route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const response = yield* LLMClient.generate( const response = yield* LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
tools: [ tools: [ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } })],
ToolDefinition.make({ name: "weather", description: "Get weather", inputSchema: { type: "object" } }),
],
}), }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
@@ -45,7 +45,7 @@ describe("Open Responses-compatible route", () => {
}, },
}, },
}) })
expect(prepared.body).toEqual({ expect(prepared.body).toMatchObject({
model: "example-model", model: "example-model",
input: [ input: [
{ role: "system", content: "You are concise." }, { role: "system", content: "You are concise." },
@@ -53,6 +53,8 @@ describe("Open Responses-compatible route", () => {
], ],
stream: true, stream: true,
}) })
expect(prepared.body.input[0]).not.toHaveProperty("id")
expect(prepared.body.input[1]).not.toHaveProperty("id")
}), }),
) )
@@ -20,7 +20,7 @@ const cacheRequest = LLM.request({
system: LARGE_CACHEABLE_SYSTEM, system: LARGE_CACHEABLE_SYSTEM,
prompt: "Say hi.", prompt: "Say hi.",
generation: { maxTokens: 16, temperature: 0 }, generation: { maxTokens: 16, temperature: 0 },
promptCacheKey: "recorded-cache-test", providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } },
}) })
const recorded = recordedTests({ const recorded = recordedTests({
@@ -69,6 +69,9 @@ describe("OpenAI Responses route", () => {
stream: true, stream: true,
max_output_tokens: 20, max_output_tokens: 20,
temperature: 0, temperature: 0,
tool_choice: undefined,
tools: undefined,
top_p: undefined,
}) })
}), }),
) )
@@ -329,7 +332,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate( yield* LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
model: Azure.configure({ model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/", baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key", apiKey: "azure-key",
headers: { authorization: "Bearer stale" }, headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"), }).responses("gpt-4.1-mini"),
@@ -410,7 +413,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
expect(prepared.body).toEqual({ expect(prepared.body).toMatchObject({
model: "gpt-4.1-mini", model: "gpt-4.1-mini",
input: [ input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] }, { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
@@ -425,6 +428,65 @@ describe("OpenAI Responses route", () => {
tools: undefined, tools: undefined,
top_p: undefined, top_p: undefined,
}) })
const call = prepared.body.input.find((item) => "type" in item && item.type === "function_call")
const output = prepared.body.input.find((item) => "type" in item && item.type === "function_call_output")
expect(call?.id).toBeUndefined()
expect(output?.id).toBeUndefined()
}),
)
it.effect("does not generate response item ids for client-created history", () =>
Effect.sync(() => {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Working." },
{ type: "reasoning", text: "Thinking." },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "done" }),
],
})
expect(canonical.messages[0]?.id).toBeUndefined()
expect(canonical.messages[1]?.id).toBeUndefined()
expect(canonical.messages[0]?.content.every((part) => part.type === "media" || part.itemId === undefined)).toBe(
true,
)
expect(canonical.messages[1]?.content[0]).not.toHaveProperty("itemId")
}),
)
it.effect("replays provider function call item ids without assigning output ids", () =>
Effect.gen(function* () {
const canonical = LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "Calling.", itemId: "plain-text" },
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: {},
providerMetadata: { openai: { itemId: "plain-call" } },
}),
]),
Message.tool({ id: "call_1", itemId: "plain-output", name: "lookup", result: "done" }),
],
})
const prepared = yield* compileRequest(canonical)
expect(canonical.messages[0]?.content.map((part) => (part.type === "media" ? undefined : part.itemId))).toEqual([
"plain-text",
undefined,
])
expect(canonical.messages[1]?.content[0]).toMatchObject({ itemId: "plain-output" })
expect(prepared.body.input).toEqual([
{ role: "assistant", id: "plain-text", content: [{ type: "output_text", text: "Calling." }] },
{ type: "function_call", id: "plain-call", call_id: "call_1", name: "lookup", arguments: "{}" },
{ type: "function_call_output", call_id: "call_1", output: '"done"' },
])
}), }),
) )
@@ -682,9 +744,9 @@ describe("OpenAI Responses route", () => {
LLM.request({ LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"), model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
prompt: "think", prompt: "think",
promptCacheKey: "session_123",
providerOptions: { providerOptions: {
openai: { openai: {
promptCacheKey: "session_123",
reasoningEffort: "high", reasoningEffort: "high",
reasoningSummary: "auto", reasoningSummary: "auto",
include: ["reasoning.encrypted_content"], include: ["reasoning.encrypted_content"],
@@ -803,16 +865,17 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("maps the request prompt cache key", () => it.effect("request OpenAI provider options override route defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: OpenAI.configure({ model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/", baseURL: "https://api.openai.test/v1/",
apiKey: "test", apiKey: "test",
providerOptions: { openai: { promptCacheKey: "model_cache" } },
}).model("gpt-4.1-mini"), }).model("gpt-4.1-mini"),
prompt: "no cache", prompt: "no cache",
promptCacheKey: "request_cache", providerOptions: { openai: { promptCacheKey: "request_cache" } },
}), }),
) )
@@ -863,9 +926,21 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello!") expect(response.text).toBe("Hello!")
expect(response.events).toEqual([ expect(response.events).toEqual([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "text-start", id: "msg_1" }, { type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" }, {
{ type: "text-delta", id: "msg_1", text: "!" }, type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "Hello",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{
type: "text-delta",
id: "msg_1",
itemId: "msg_1",
text: "!",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1" },
{ {
type: "step-finish", type: "step-finish",
@@ -922,17 +997,20 @@ describe("OpenAI Responses route", () => {
{ {
type: "text", type: "text",
text: "Checking.", text: "Checking.",
providerMetadata: { openai: { phase: "commentary" } }, itemId: "msg_commentary",
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
}, },
{ {
type: "text", type: "text",
text: "Finished.", text: "Finished.",
providerMetadata: { openai: { phase: "final_answer" } }, itemId: "msg_final",
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
}, },
{ {
type: "text", type: "text",
text: "Unclassified.", text: "Unclassified.",
providerMetadata: { openai: { phase: null } }, itemId: "msg_null",
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
}, },
]) ])
@@ -940,16 +1018,19 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
role: "assistant", role: "assistant",
id: "msg_commentary",
content: [{ type: "output_text", text: "Checking." }], content: [{ type: "output_text", text: "Checking." }],
phase: "commentary", phase: "commentary",
}, },
{ {
role: "assistant", role: "assistant",
id: "msg_final",
content: [{ type: "output_text", text: "Finished." }], content: [{ type: "output_text", text: "Finished." }],
phase: "final_answer", phase: "final_answer",
}, },
{ {
role: "assistant", role: "assistant",
id: "msg_null",
content: [{ type: "output_text", text: "Unclassified." }], content: [{ type: "output_text", text: "Unclassified." }],
phase: null, phase: null,
}, },
@@ -1042,12 +1123,24 @@ describe("OpenAI Responses route", () => {
) )
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1" }, { type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "First" }, {
{ type: "text-end", id: "msg_1" }, type: "text-delta",
{ type: "text-start", id: "msg_2" }, id: "msg_1",
{ type: "text-delta", id: "msg_2", text: "Second" }, itemId: "msg_1",
{ type: "text-end", id: "msg_2" }, text: "First",
providerMetadata: { openai: { itemId: "msg_1" } },
},
{ type: "text-end", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-start", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
{
type: "text-delta",
id: "msg_2",
itemId: "msg_2",
text: "Second",
providerMetadata: { openai: { itemId: "msg_2" } },
},
{ type: "text-end", id: "msg_2", itemId: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
]) ])
}), }),
) )
@@ -1067,9 +1160,15 @@ describe("OpenAI Responses route", () => {
expect(response.text).toBe("Hello") expect(response.text).toBe("Hello")
expect(response.events).toMatchObject([ expect(response.events).toMatchObject([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "reasoning-start", id: "rs_1" }, { type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-delta", id: "rs_1", text: "thinking" }, {
{ type: "text-start", id: "msg_1" }, type: "reasoning-delta",
id: "rs_1",
itemId: "rs_1",
text: "thinking",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
{ type: "text-delta", id: "msg_1", text: "Hello" }, { type: "text-delta", id: "msg_1", text: "Hello" },
{ type: "reasoning-end", id: "rs_1" }, { type: "reasoning-end", id: "rs_1" },
{ type: "text-end", id: "msg_1" }, { type: "text-end", id: "msg_1" },
@@ -1078,8 +1177,8 @@ describe("OpenAI Responses route", () => {
]) ])
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.message.content).toEqual([ expect(response.message.content).toEqual([
{ type: "reasoning", text: "thinking" }, { type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "text", text: "Hello" }, { type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
]) ])
}), }),
) )
@@ -1110,6 +1209,7 @@ describe("OpenAI Responses route", () => {
expect.objectContaining({ expect.objectContaining({
type: "reasoning-end", type: "reasoning-end",
id: "rs_1", id: "rs_1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}), }),
) )
@@ -1150,19 +1250,34 @@ describe("OpenAI Responses route", () => {
{ {
type: "reasoning-start", type: "reasoning-start",
id: "rs_1:0", id: "rs_1:0",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
}, },
{ type: "reasoning-delta", id: "rs_1:0", text: "First" }, {
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } }, type: "reasoning-delta",
id: "rs_1:0",
itemId: "rs_1",
text: "First",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ {
type: "reasoning-start", type: "reasoning-start",
id: "rs_1:1", id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } }, providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
}, },
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" }, {
type: "reasoning-delta",
id: "rs_1:1",
itemId: "rs_1",
text: "Second",
providerMetadata: { openai: { itemId: "rs_1" } },
},
{ {
type: "reasoning-end", type: "reasoning-end",
id: "rs_1:1", id: "rs_1:1",
itemId: "rs_1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}, },
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } }, { type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
@@ -1200,8 +1315,8 @@ describe("OpenAI Responses route", () => {
) )
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([ 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:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } }, { type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
]) ])
}), }),
) )
@@ -1249,7 +1364,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
], ],
}) })
expect(body.input[1]).not.toHaveProperty("id") expect(body.input[1]).toHaveProperty("id", "rs_1")
return input.respond( return input.respond(
sseEvents( sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." }, { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
@@ -1296,6 +1411,7 @@ describe("OpenAI Responses route", () => {
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] }, { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [{ type: "summary_text", text: "Checked order." }], summary: [{ type: "summary_text", text: "Checked order." }],
}, },
@@ -1304,7 +1420,7 @@ describe("OpenAI Responses route", () => {
}), }),
) )
it.effect("references stored reasoning items by id", () => it.effect("replays complete stored reasoning items with their id", () =>
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
@@ -1322,7 +1438,14 @@ 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: undefined,
},
])
}), }),
) )
@@ -1431,6 +1554,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([ expect(prepared.body.input).toEqual([
{ {
type: "reasoning", type: "reasoning",
id: "rs_1",
encrypted_content: "encrypted-state", encrypted_content: "encrypted-state",
summary: [ summary: [
{ type: "summary_text", text: "First" }, { type: "summary_text", text: "First" },
@@ -1510,6 +1634,7 @@ describe("OpenAI Responses route", () => {
outputTokens: 1, outputTokens: 1,
nonCachedInputTokens: 5, nonCachedInputTokens: 5,
cacheReadInputTokens: undefined, cacheReadInputTokens: undefined,
cacheWriteInputTokens: undefined,
reasoningTokens: undefined, reasoningTokens: undefined,
totalTokens: 6, totalTokens: 6,
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
@@ -1520,30 +1645,35 @@ describe("OpenAI Responses route", () => {
{ {
type: "tool-input-start", type: "tool-input-start",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } }, providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ {
type: "tool-input-delta", type: "tool-input-delta",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
text: '{"query"', text: '{"query"',
}, },
{ {
type: "tool-input-delta", type: "tool-input-delta",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
text: ':"weather"}', text: ':"weather"}',
}, },
{ {
type: "tool-input-end", type: "tool-input-end",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
providerMetadata: { openai: { itemId: "item_1" } }, providerMetadata: { openai: { itemId: "item_1" } },
}, },
{ {
type: "tool-call", type: "tool-call",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
input: { query: "weather" }, input: { query: "weather" },
providerExecuted: undefined, providerExecuted: undefined,
@@ -1563,6 +1693,17 @@ describe("OpenAI Responses route", () => {
usage, usage,
}, },
]) ])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "call_1",
itemId: "item_1",
name: "lookup",
input: { query: "weather" },
providerExecuted: undefined,
providerMetadata: { openai: { itemId: "item_1" } },
},
])
}), }),
) )
@@ -1595,6 +1736,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({ expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error", type: "tool-input-error",
id: "call_1", id: "call_1",
itemId: "item_1",
name: "lookup", name: "lookup",
raw: '{"query":"partial', raw: '{"query":"partial',
}) })
@@ -1651,6 +1793,7 @@ describe("OpenAI Responses route", () => {
{ {
type: "tool-call", type: "tool-call",
id: "ws_1", id: "ws_1",
itemId: "ws_1",
name: "web_search", name: "web_search",
input: { type: "search", query: "effect 4" }, input: { type: "search", query: "effect 4" },
providerExecuted: true, providerExecuted: true,
@@ -1659,11 +1802,35 @@ describe("OpenAI Responses route", () => {
{ {
type: "tool-result", type: "tool-result",
id: "ws_1", id: "ws_1",
itemId: "ws_1",
name: "web_search", name: "web_search",
result: { type: "json", value: item }, result: { type: "json", value: item },
providerExecuted: true, providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
output: undefined,
},
])
expect(response.message.content).toEqual([
{
type: "tool-call",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
input: { type: "search", query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } }, providerMetadata: { openai: { itemId: "ws_1" } },
}, },
{
type: "tool-result",
id: "ws_1",
itemId: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1", item } },
metadata: undefined,
cache: undefined,
},
]) ])
}), }),
) )
@@ -1741,6 +1908,7 @@ describe("OpenAI Responses route", () => {
expect(toolCall).toEqual({ expect(toolCall).toEqual({
type: "tool-call", type: "tool-call",
id: "ci_1", id: "ci_1",
itemId: "ci_1",
name: "code_interpreter", name: "code_interpreter",
input: { code: "print(1+1)", container_id: "cnt_xyz" }, input: { code: "print(1+1)", container_id: "cnt_xyz" },
providerExecuted: true, providerExecuted: true,
@@ -1750,10 +1918,12 @@ describe("OpenAI Responses route", () => {
expect(toolResult).toEqual({ expect(toolResult).toEqual({
type: "tool-result", type: "tool-result",
id: "ci_1", id: "ci_1",
itemId: "ci_1",
name: "code_interpreter", name: "code_interpreter",
result: { type: "json", value: item }, result: { type: "json", value: item },
providerExecuted: true, providerExecuted: true,
providerMetadata: { openai: { itemId: "ci_1" } }, providerMetadata: { openai: { itemId: "ci_1", item } },
output: undefined,
}) })
}), }),
) )
+1 -1
View File
@@ -162,6 +162,7 @@ describe("OpenRouter", () => {
openrouter: { openrouter: {
usage: true, usage: true,
reasoning: { effort: "high" }, reasoning: { effort: "high" },
promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"], models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true }, provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }], plugins: [{ id: "response-healing" }],
@@ -173,7 +174,6 @@ describe("OpenRouter", () => {
}, },
}).model("anthropic/claude-3.7-sonnet:thinking"), }).model("anthropic/claude-3.7-sonnet:thinking"),
prompt: "Think briefly.", prompt: "Think briefly.",
promptCacheKey: "session_123",
}), }),
) )
+37
View File
@@ -49,6 +49,43 @@ describe("LLMResponse reducer", () => {
expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) expect(state.message.content).toEqual([{ type: "text", text: "partial" }])
}) })
test("assembles response item identity and provider metadata", () => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.textDelta({
id: "text-block",
itemId: "msg_existing",
text: "Answer",
providerMetadata: { openai: { itemId: "msg_existing" } },
}),
LLMEvent.textEnd({ id: "text-block", itemId: "msg_existing" }),
LLMEvent.reasoningStart({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.reasoningDelta({
id: "reasoning-block",
itemId: "rs_existing",
text: "Thought",
providerMetadata: { openai: { itemId: "rs_existing" } },
}),
LLMEvent.reasoningEnd({ id: "reasoning-block", itemId: "rs_existing" }),
LLMEvent.finish({ reason: { normalized: "stop" } }),
])
expect(response?.message.content).toEqual([
{
type: "text",
text: "Answer",
itemId: "msg_existing",
providerMetadata: { openai: { itemId: "msg_existing" } },
},
{
type: "reasoning",
text: "Thought",
itemId: "rs_existing",
providerMetadata: { openai: { itemId: "rs_existing" } },
},
])
})
test("does not complete ended content without a terminal finish", () => { test("does not complete ended content without a terminal finish", () => {
const state = reduce([ const state = reduce([
LLMEvent.textStart({ id: "t1" }), LLMEvent.textStart({ id: "t1" }),
+29 -5
View File
@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }]) expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "count:2" }) expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] }) expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_projected", id: "call_projected",
name: "projected", name: "projected",
@@ -180,6 +180,7 @@ describe("LLMClient tools", () => {
output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] }, output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
}), }),
]) ])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}), }),
) )
@@ -197,7 +198,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }), LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
) )
expect(dispatched.events).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_1", id: "call_1",
name: "tool", name: "tool",
@@ -206,12 +207,13 @@ describe("LLMClient tools", () => {
providerMetadata, providerMetadata,
}), }),
]) ])
expect(dispatched.events[0]?.itemId).toBeUndefined()
const failed = yield* ToolRuntime.dispatch( const failed = yield* ToolRuntime.dispatch(
{}, {},
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }), LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
) )
expect(failed.events).toEqual([ expect(failed.events).toMatchObject([
LLMEvent.toolError({ LLMEvent.toolError({
id: "call_2", id: "call_2",
name: "missing", name: "missing",
@@ -225,6 +227,27 @@ describe("LLMClient tools", () => {
providerMetadata, providerMetadata,
}), }),
]) ])
const errorItemID = failed.events.find(LLMEvent.is.toolError)?.itemId
const resultItemID = failed.events.find(LLMEvent.is.toolResult)?.itemId
expect(errorItemID).toBeUndefined()
expect(resultItemID).toBeUndefined()
}),
)
it.effect("does not derive a function output item id from the function call item id", () =>
Effect.gen(function* () {
const tool = Tool.make({
description: "Return text.",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("hello"),
})
const dispatched = yield* ToolRuntime.dispatch(
{ tool },
LLMEvent.toolCall({ id: "call_1", itemId: "fc_existing", name: "tool", input: {} }),
)
expect(dispatched.events.find(LLMEvent.is.toolResult)?.itemId).toBeUndefined()
}), }),
) )
@@ -437,7 +460,7 @@ describe("LLMClient tools", () => {
) )
expect(dispatched.result).toEqual(callerOwned) expect(dispatched.result).toEqual(callerOwned)
expect(dispatched.events).toEqual([ expect(dispatched.events).toMatchObject([
LLMEvent.toolResult({ LLMEvent.toolResult({
id: "call_1", id: "call_1",
name: "eventful", name: "eventful",
@@ -445,6 +468,7 @@ describe("LLMClient tools", () => {
output: { structured: { ok: true }, content: [] }, output: { structured: { ok: true }, content: [] },
}), }),
]) ])
expect(dispatched.events[0]?.itemId).toBeUndefined()
}), }),
) )
@@ -177,8 +177,8 @@ test("shows all and expands historical diff summary without overlap", async ({ p
const firstUser = userMessage(undefined, { const firstUser = userMessage(undefined, {
summary: { summary: {
diffs: Array.from({ length: 12 }, (_, index) => ({ diffs: Array.from({ length: 12 }, (_, index) => ({
file: `src/diff-${index}.ts`, file: `src/diff-${index}.ts`,
status: "modified", status: "modified",
additions: 1, additions: 1,
deletions: 1, deletions: 1,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`, patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
@@ -85,7 +85,8 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
@@ -181,17 +181,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/provider") if (url.pathname === "/api/provider")
return json(route, { return json(route, {
location: { directory }, location: { directory },
data: [ data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
{
id: remote ? "server-b" : "server-a",
name: remote ? "Server B Provider" : "Server A Provider",
package: "test",
},
],
}) })
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] }) if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) }) if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
@@ -58,7 +58,8 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event") if (url.pathname === "/api/event")
return sse(route, url.pathname === "/api/event")
if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active") if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
@@ -171,8 +171,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
expect(bottomGap).toBeLessThanOrEqual(16) expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => { const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url()) const url = new URL(request.url())
return ( return (
url.pathname === "/api/vcs/diff" && url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
@@ -86,10 +86,15 @@ test.describe("session timeline projection", () => {
], ],
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } }, { summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
) )
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], { const aborted = assistantMessage(
id: "msg_1001_assistant_aborted", [
error: { name: "MessageAbortedError", data: { message: "Stopped" } }, { id: "prt_before_abort", type: "text", text: "Before interruption" },
}) ],
{
id: "msg_1001_assistant_aborted",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
},
)
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], { const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
id: "msg_1002_assistant_failed", id: "msg_1002_assistant_failed",
error: { error: {
@@ -89,7 +89,8 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback() if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {}) return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route) if (url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
@@ -300,8 +300,9 @@ export const fixture = {
.filter((message) => message.info.role === "user") .filter((message) => message.info.role === "user")
.map((message) => message.info.id), .map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap(currentPartIDs), targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")! expandedShellPartID: targetMessages
.callID, .flatMap((message) => message.parts)
.find((part) => part.tool === "bash")!.callID,
}, },
} }
+23 -25
View File
@@ -144,8 +144,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config), location: location(config),
data: currentProviders(providerConfig(config)), data: currentProviders(providerConfig(config)),
}) })
if (path === "/api/model") if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default") if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) }) return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] }) if (path === "/api/integration") return json(route, { location: location(config), data: [] })
@@ -207,8 +206,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1] const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) { if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead)) const value = await config.fileContent(decodeURIComponent(fileRead))
const content = const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } }) return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
} }
if (path === "/api/fs/find" && config.findFiles) { if (path === "/api/fs/find" && config.findFiles) {
@@ -283,8 +281,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
} }
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
return json(route, true)
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
return json(route, true) return json(route, true)
if ( if (
@@ -389,13 +386,11 @@ function providerConfig(config: MockServerConfig) {
function currentProviders(value: unknown) { function currentProviders(value: unknown) {
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : [] if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
return value.all return value.all.filter(record).flatMap((provider) =>
.filter(record) typeof provider.id === "string" && typeof provider.name === "string"
.flatMap((provider) => ? [{ id: provider.id, name: provider.name, package: provider.id }]
typeof provider.id === "string" && typeof provider.name === "string" : [],
? [{ id: provider.id, name: provider.name, package: provider.id }] )
: [],
)
} }
function currentModels(value: unknown) { function currentModels(value: unknown) {
@@ -445,7 +440,9 @@ function currentDefaultModel(value: unknown) {
if (!record(value) || !record(value.default)) return null if (!record(value) || !record(value.default)) return null
const selected = value.default const selected = value.default
const models = currentModels(value) const models = currentModels(value)
return models.find((model) => model.providerID === selected.providerID && model.id === selected.modelID) ?? null return models.find(
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
) ?? null
} }
function currentPermission(value: unknown) { function currentPermission(value: unknown) {
@@ -565,16 +562,22 @@ function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
} }
function mentionFrom(value: Record<string, unknown> | undefined) { function mentionFrom(value: Record<string, unknown> | undefined) {
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number") if (
!value ||
typeof value.value !== "string" ||
typeof value.start !== "number" ||
typeof value.end !== "number"
)
return return
return { text: value.value, start: value.start, end: value.end } return { text: value.value, start: value.start, end: value.end }
} }
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] { function legacyAssistantContent(
part: Record<string, unknown>,
created: number,
): SessionMessageAssistant["content"] {
if (part.type === "text" && typeof part.text === "string") if (part.type === "text" && typeof part.text === "string")
return [ return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
]
if (part.type === "reasoning" && typeof part.text === "string") { if (part.type === "reasoning" && typeof part.text === "string") {
const time = record(part.time) ? part.time : undefined const time = record(part.time) ? part.time : undefined
return [ return [
@@ -615,12 +618,7 @@ function legacyAssistantContent(part: Record<string, unknown>, created: number):
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}), ...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
} }
if (state.status === "pending") if (state.status === "pending")
return [ return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
{
...base,
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
},
]
if (state.status === "completed") if (state.status === "completed")
return [ return [
{ {
+2 -1
View File
@@ -174,7 +174,8 @@ export async function installSseTransport<T>(
const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init) const request = new Request(input, init)
const url = new URL(request.url) const url = new URL(request.url)
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request) if (url.origin !== server || url.pathname !== "/api/event")
return originalFetch(request)
const id = ++nextConnectionID const id = ++nextConnectionID
const record = { const record = {
+1 -4
View File
@@ -20,10 +20,7 @@
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script> <script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
</head> </head>
<body <body data-new-layout class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep">
data-new-layout
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div> <div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
<script src="/src/entry.tsx" type="module"></script> <script src="/src/entry.tsx" type="module"></script>
+8 -1
View File
@@ -9,7 +9,14 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" import {
type BaseRouterProps,
Navigate,
Route,
Router,
useParams,
useSearchParams,
} from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect" import { Effect } from "effect"
import { import {
@@ -1,4 +1,4 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -40,8 +40,6 @@ import { decode64 } from "@/utils/base64"
const CUSTOM_ID = "_custom" const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }> type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type IntegrationForm = NonNullable<ConnectMethod["form"]>[number]
type StringForm = Extract<IntegrationForm, { type: "string" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) { export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined }) const [store, setStore] = createStore({ selected: undefined as string | undefined })
@@ -436,16 +434,16 @@ function ProviderConnection(props: {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
formAnswer: undefined as FormAnswer | undefined, promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "form", state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
type Action = type Action =
| { type: "method.select"; index: number } | { type: "method.select"; index: number }
| { type: "method.reset" } | { type: "method.reset" }
| { type: "auth.form" } | { type: "auth.prompt" }
| { type: "auth.answer"; answer: FormAnswer | undefined } | { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" } | { type: "auth.pending" }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string } | { type: "auth.error"; error: string }
@@ -456,7 +454,7 @@ function ProviderConnection(props: {
if (action.type === "method.select") { if (action.type === "method.select") {
draft.methodIndex = action.index draft.methodIndex = action.index
draft.authorization = undefined draft.authorization = undefined
draft.formAnswer = undefined draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
@@ -464,18 +462,18 @@ function ProviderConnection(props: {
if (action.type === "method.reset") { if (action.type === "method.reset") {
draft.methodIndex = undefined draft.methodIndex = undefined
draft.authorization = undefined draft.authorization = undefined
draft.formAnswer = undefined draft.promptInputs = undefined
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.form") { if (action.type === "auth.prompt") {
draft.state = "form" draft.state = "prompt"
draft.error = undefined draft.error = undefined
return return
} }
if (action.type === "auth.answer") { if (action.type === "auth.inputs") {
draft.formAnswer = action.answer draft.promptInputs = action.inputs
draft.state = undefined draft.state = undefined
draft.error = undefined draft.error = undefined
return return
@@ -533,7 +531,7 @@ function ProviderConnection(props: {
return fallback return fallback
} }
async function selectMethod(index: number, answer?: FormAnswer) { async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) { if (timer.current !== undefined) {
clearTimeout(timer.current) clearTimeout(timer.current)
timer.current = undefined timer.current = undefined
@@ -542,17 +540,9 @@ function ProviderConnection(props: {
const method = methods()[index] const method = methods()[index]
dispatch({ type: "method.select", index }) dispatch({ type: "method.select", index })
if (method.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
}
if (method.type === "key") {
dispatch({ type: "auth.answer", answer })
return
}
if (method.type === "oauth") { if (method.type === "oauth") {
if (method.form?.some((field) => field.type !== "string")) { if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.error", error: "This authentication form contains unsupported fields" }) dispatch({ type: "auth.prompt" })
return return
} }
dispatch({ type: "auth.pending" }) dispatch({ type: "auth.pending" })
@@ -560,7 +550,7 @@ function ProviderConnection(props: {
.api.integration.oauth.connect({ .api.integration.oauth.connect({
integrationID: props.provider, integrationID: props.provider,
methodID: method.id, methodID: method.id,
...(answer ? { answer } : {}), inputs: inputs ?? {},
location: location(), location: location(),
}) })
.then((x) => { .then((x) => {
@@ -574,42 +564,41 @@ function ProviderConnection(props: {
} }
} }
function AuthFormView() { function AuthPromptsView() {
const [formStore, setFormStore] = createStore({ const [formStore, setFormStore] = createStore({
value: {} as Record<string, string>, value: {} as Record<string, string>,
index: 0, index: 0,
}) })
const fields = createMemo<StringForm[]>(() => { const prompts = createMemo(() => {
const value = method() const value = method()
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : [])) return value?.type === "oauth" ? (value.prompts ?? []) : []
}) })
const matches = (field: StringForm, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
return (field.when ?? []).every((condition) => { if (!prompt.when) return true
const actual = value[condition.key] const actual = value[prompt.when.key]
if (actual === undefined) return false if (actual === undefined) return false
return condition.op === "eq" ? actual === condition.value : actual !== condition.value return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
})
} }
const current = createMemo(() => { const current = createMemo(() => {
const all = fields() const all = prompts()
const index = all.findIndex((field, index) => index >= formStore.index && matches(field, formStore.value)) const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return if (index === -1) return
return { return {
index, index,
field: all[index], prompt: all[index],
} }
}) })
const valid = createMemo(() => { const valid = createMemo(() => {
const item = current() const item = current()
if (!item || item.field.options) return false if (!item || item.prompt.type !== "text") return false
if (!item.field.required) return true const value = formStore.value[item.prompt.key] ?? ""
return (formStore.value[item.field.key] ?? "").trim().length > 0 return value.trim().length > 0
}) })
async function next(index: number, value: Record<string, string>) { async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return if (store.methodIndex === undefined) return
const next = fields().findIndex((field, i) => i > index && matches(field, value)) const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) { if (next !== -1) {
setFormStore("index", next) setFormStore("index", next)
return return
@@ -620,60 +609,60 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
const item = current() const item = current()
if (!item || item.field.options) return if (!item || item.prompt.type !== "text") return
if (!valid()) return if (!valid()) return
await next(item.index, formStore.value) await next(item.index, formStore.value)
} }
const item = () => current() const item = () => current()
const text = createMemo(() => { const text = createMemo(() => {
const field = item()?.field const prompt = item()?.prompt
if (!field || field.options) return if (!prompt || prompt.type !== "text") return
return field return prompt
}) })
const select = createMemo(() => { const select = createMemo(() => {
const field = item()?.field const prompt = item()?.prompt
if (!field?.options) return if (!prompt || prompt.type !== "select") return
return field return prompt
}) })
return ( return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch> <Switch>
<Match when={item()?.field.options === undefined}> <Match when={item()?.prompt.type === "text"}>
<TextField <TextField
type="text" type="text"
label={text()?.title ?? ""} label={text()?.message ?? ""}
placeholder={text()?.placeholder} placeholder={text()?.placeholder}
value={text() ? (formStore.value[text()!.key] ?? "") : ""} value={text() ? (formStore.value[text()!.key] ?? "") : ""}
onChange={(value) => { onChange={(value) => {
const field = text() const prompt = text()
if (!field) return if (!prompt) return
setFormStore("value", field.key, value) setFormStore("value", prompt.key, value)
}} }}
/> />
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}> <Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
{language.t("common.continue")} {language.t("common.continue")}
</Button> </Button>
</Match> </Match>
<Match when={item()?.field.options !== undefined}> <Match when={item()?.prompt.type === "select"}>
<div class="w-full flex flex-col gap-1.5"> <div class="w-full flex flex-col gap-1.5">
<div class="text-14-regular text-text-base">{select()?.title}</div> <div class="text-14-regular text-text-base">{select()?.message}</div>
<div> <div>
<List <List
class="px-3" class="px-3"
items={select()?.options ?? []} items={select()?.options ?? []}
key={(x) => x.value} key={(x) => x.value}
current={select()?.options?.find((x) => x.value === formStore.value[select()!.key])} current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
onSelect={(value) => { onSelect={(value) => {
if (!value) return if (!value) return
const field = select() const prompt = select()
if (!field) return if (!prompt) return
const nextValue = { const nextValue = {
...formStore.value, ...formStore.value,
[field.key]: value.value, [prompt.key]: value.value,
} }
setFormStore("value", field.key, value.value) setFormStore("value", prompt.key, value.value)
void next(item()!.index, nextValue) void next(item()!.index, nextValue)
}} }}
> >
@@ -683,7 +672,7 @@ function ProviderConnection(props: {
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" /> <div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div> </div>
<span>{option.label}</span> <span>{option.label}</span>
<span class="text-14-regular text-text-weak">{option.description}</span> <span class="text-14-regular text-text-weak">{option.hint}</span>
</div> </div>
)} )}
</List> </List>
@@ -831,7 +820,6 @@ function ProviderConnection(props: {
integrationID: props.provider, integrationID: props.provider,
location: location(), location: location(),
key: apiKey, key: apiKey,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
}) })
await complete() await complete()
} }
@@ -1155,8 +1143,8 @@ function ProviderConnection(props: {
</div> </div>
</div> </div>
</Match> </Match>
<Match when={store.state === "form"}> <Match when={store.state === "prompt"}>
<AuthFormView /> <AuthPromptsView />
</Match> </Match>
<Match when={store.state === "error"}> <Match when={store.state === "error"}>
<div class="text-14-regular text-text-base"> <div class="text-14-regular text-text-base">
@@ -123,7 +123,9 @@ export const SettingsProvidersV2: Component<{
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`) if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all( await Promise.all(
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })), credentials.map((credential) =>
serverSdk().api.credential.remove({ credentialID: credential.id, location }),
),
) )
showToast({ showToast({
variant: "success", variant: "success",
@@ -5,16 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { import { type Accessor, createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
type Accessor,
createEffect,
createMemo,
createResource,
For,
type JSXElement,
onCleanup,
Show,
} from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -300,10 +291,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const [pluginList] = createResource( const [pluginList] = createResource(
() => (props.shown() ? sdk().directory : undefined), () => (props.shown() ? sdk().directory : undefined),
(directory) => (directory) => sdk().api.plugin.list({ location: { directory } }).then((result) => result.data),
sdk()
.api.plugin.list({ location: { directory } })
.then((result) => result.data),
) )
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id)) const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
+4 -1
View File
@@ -190,7 +190,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
.find((item) => ServerConnection.key(item) === (route.server ?? server.key)) .find((item) => ServerConnection.key(item) === (route.server ?? server.key))
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
}, },
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}), ({ route, sdk }) =>
sdk.api.session
.get({ sessionID: route.sessionId })
.catch(() => {}),
) )
const matchRoute = (route: LayoutRoute) => { const matchRoute = (route: LayoutRoute) => {
+3 -1
View File
@@ -124,7 +124,9 @@ export const createDirSyncContext = (
const [store, setStore] = current() const [store, setStore] = current()
setStore("limit", (value) => value + count) setStore("limit", (value) => value + count)
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data.sort((a, b) => cmp(a.id, b.id)).slice(0, store.limit) const sessions = response.data
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
sessions.forEach(serverSync.session.remember) sessions.forEach(serverSync.session.remember)
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
}, },
+124 -76
View File
@@ -1,4 +1,9 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type {
Config,
Path,
Project,
ProviderAuthResponse,
} from "@/types"
import type { import type {
AgentListInput, AgentListInput,
AgentListOutput, AgentListOutput,
@@ -26,7 +31,12 @@ import { batch } from "solid-js"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import type { ServerSession } from "../server-session" import type { ServerSession } from "../server-session"
import { cmp, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils" import {
cmp,
normalizeAgentList,
normalizeProjectInfo,
normalizeProviderList,
} from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
@@ -138,7 +148,10 @@ export async function bootstrapGlobal(input: {
}) { }) {
const slow = [ const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)), () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)), () =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)), () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() => () =>
input.queryClient input.queryClient
@@ -198,7 +211,11 @@ function warmSessions(input: {
).then(() => undefined) ).then(() => undefined)
} }
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) => export const loadProvidersQuery = (
scope: ServerScope,
directory: string | null,
sdk: CatalogApi,
) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "providers"], queryKey: [scope, directory, "providers"],
queryFn: () => queryFn: () =>
@@ -225,16 +242,28 @@ type ReferenceListApi = {
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput> readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
} }
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) => export const loadAgentsQuery = (
scope: ServerScope,
directory: string,
sdk: AgentListApi,
) =>
queryOptions({ queryOptions({
queryKey: [scope, directory, "agents"], queryKey: [scope, directory, "agents"],
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))), queryFn: () =>
retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
}) })
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> => export const loadCommands = (
directory: string,
api: CommandListApi,
): Promise<CommandInfo[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data)) retry(() => api.list({ location: { directory } }).then((result) => result.data))
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) => export const loadPathQuery = (
scope: ServerScope,
directory: string | null,
api: LocationApi,
) =>
queryOptions<Path>({ queryOptions<Path>({
queryKey: [scope, directory, "path"], queryKey: [scope, directory, "path"],
queryFn: () => queryFn: () =>
@@ -247,10 +276,15 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api:
})), })),
}) })
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) => export const loadReferencesQuery = (
scope: ServerScope,
directory: string,
api: ReferenceListApi,
) =>
queryOptions<ReferenceInfo[]>({ queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const, queryKey: [scope, directory, "references"] as const,
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []), queryFn: () =>
retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
placeholderData: [], placeholderData: [],
}) })
@@ -319,41 +353,46 @@ export async function bootstrapDirectory(input: {
})), })),
input.mcp && input.mcp &&
(() => (() =>
loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))), loadCommands(input.directory, input.api.command).then((commands) =>
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.api.reference)), input.setStore("command", commands),
)),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, input.directory, input.api.reference),
),
() => () =>
retry(() => retry(() =>
input.api.permission.request input.api.permission.request
.list({ location: { directory: input.directory } }) .list({ location: { directory: input.directory } })
.then((result) => result.data) .then((result) => result.data)
.then((permissions) => { .then((permissions) => {
const ids = permissions.map((permission) => permission.sessionID) const ids = permissions.map((permission) => permission.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
permissions.filter((permission) => !!permission.id && !!permission.sessionID), permissions.filter((permission) => !!permission.id && !!permission.sessionID),
) )
const warm = input.session const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() => return warm.then(() =>
batch(() => { batch(() => {
const current = input.session?.data.permission ?? input.store.permission const current = input.session?.data.permission ?? input.store.permission
for (const sessionID of Object.keys(current)) { for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("permission", sessionID, []) if (input.session) input.session.set("permission", sessionID, [])
if (!input.session) input.setStore("permission", sessionID, []) if (!input.session) input.setStore("permission", sessionID, [])
} }
for (const [sessionID, permissions] of Object.entries(grouped)) { for (const [sessionID, permissions] of Object.entries(grouped)) {
const value = reconcile( const value = reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" }, { key: "id" },
) )
if (input.session) input.session.set("permission", sessionID, value) if (input.session) input.session.set("permission", sessionID, value)
if (!input.session) input.setStore("permission", sessionID, value) if (!input.session) input.setStore("permission", sessionID, value)
} }
}), }),
) )
}), }),
), ),
() => () =>
retry(() => retry(() =>
@@ -361,47 +400,56 @@ export async function bootstrapDirectory(input: {
.list({ location: { directory: input.directory } }) .list({ location: { directory: input.directory } })
.then((result) => result.data) .then((result) => result.data)
.then((questions) => { .then((questions) => {
const ids = questions.map((question) => question.sessionID) const ids = questions.map((question) => question.sessionID)
const grouped = groupBySession( const grouped = groupBySession(
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
) )
const warm = input.session const warm = input.session
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
return warm.then(() => return warm.then(() =>
batch(() => { batch(() => {
const current = input.session?.data.question ?? input.store.question const current = input.session?.data.question ?? input.store.question
for (const sessionID of Object.keys(current)) { for (const sessionID of Object.keys(current)) {
if (grouped[sessionID]) continue if (grouped[sessionID]) continue
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
if (input.session) input.session.set("question", sessionID, []) if (input.session) input.session.set("question", sessionID, [])
if (!input.session) input.setStore("question", sessionID, []) if (!input.session) input.setStore("question", sessionID, [])
} }
for (const [sessionID, questions] of Object.entries(grouped)) { for (const [sessionID, questions] of Object.entries(grouped)) {
const value = reconcile( const value = reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" }, { key: "id" },
) )
if (input.session) input.session.set("question", sessionID, value) if (input.session) input.session.set("question", sessionID, value)
if (!input.session) input.setStore("question", sessionID, value) if (!input.session) input.setStore("question", sessionID, value)
} }
}), }),
) )
}), }),
), ),
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp && input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))), (() =>
input.queryClient.fetchQuery(
loadMcpQuery(input.scope, input.directory, input.api.mcp),
)),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp),
)),
() => () =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api)).catch((err) => { input.queryClient
const project = getFilename(input.directory) .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
showToast({ .catch((err) => {
variant: "error", const project = getFilename(input.directory)
title: input.translate("toast.project.reloadFailed.title", { project }), showToast({
description: formatServerError(err, input.translate), variant: "error",
}) title: input.translate("toast.project.reloadFailed.title", { project }),
}), description: formatServerError(err, input.translate),
})
}),
].filter(Boolean) as (() => Promise<any>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
@@ -1,13 +1,12 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types"
import type { import type {
FileDiffInfo, Message,
PermissionRequest, Part,
QuestionRequest, Project,
SessionInfo, Todo,
SessionStatus, } from "@/types"
} from "@opencode-ai/client/promise" import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
@@ -81,9 +81,9 @@ describe("Home V2 session index", () => {
]) ])
expect(result).toEqual([ expect(result).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "root", id: "root",
location: { directory: "/project" }, location: { directory: "/project" },
projectID: "project", projectID: "project",
title: "root", title: "root",
time: { created: 1, updated: 30 }, time: { created: 1, updated: 30 },
+10 -1
View File
@@ -1,4 +1,13 @@
import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } from "@/types" import type {
Agent,
Config,
LspStatus,
Message,
Part,
Path,
Todo,
VcsInfo,
} from "@/types"
import type { import type {
FileDiffInfo, FileDiffInfo,
PermissionRequest, PermissionRequest,
+280 -279
View File
@@ -67,74 +67,64 @@ function currentMessages(data: MessageResponse["data"]): SessionMessageInfo[] {
}, },
] ]
} }
return [ return [{
{ id: item.info.id,
id: item.info.id, type: "assistant",
type: "assistant", agent: item.info.agent,
agent: item.info.agent, model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, content: item.parts.flatMap((part): SessionMessageAssistant["content"] => {
content: item.parts.flatMap((part): SessionMessageAssistant["content"] => { if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "text") return [{ type: "text", text: part.text }] if (part.type === "reasoning")
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
},
]
if (part.type !== "tool") return []
const state: SessionMessageAssistantTool["state"] = (() => {
if (part.state.status === "pending")
return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
if (part.state.status === "running")
return {
status: "running" as const,
input: part.state.input as CurrentToolObject,
metadata: (part.state.metadata ?? {}) as CurrentToolObject,
}
if (part.state.status === "error")
return {
status: "error" as const,
input: part.state.input as CurrentToolObject,
error: { type: "tool_error", message: part.state.error },
metadata: part.state.metadata as CurrentToolObject | undefined,
}
return {
status: "completed" as const,
input: part.state.input as CurrentToolObject,
content: [{ type: "text" as const, text: part.state.output }],
metadata: part.state.metadata as CurrentToolObject,
}
})()
return [ return [
{ {
id: part.id, type: "reasoning",
type: "tool" as const, text: part.text,
name: part.tool, time: part.time ? { created: part.time.start, completed: part.time.end } : undefined,
state,
time: {
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
ran: part.state.status === "pending" ? undefined : part.state.time.start,
completed:
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
},
}, },
] ]
}), if (part.type !== "tool") return []
time: item.info.time, const state: SessionMessageAssistantTool["state"] = (() => {
cost: item.info.cost, if (part.state.status === "pending") return { status: "streaming" as const, input: JSON.stringify(part.state.input) }
tokens: item.info.tokens, if (part.state.status === "running")
finish: item.info.finish as return {
| "stop" status: "running" as const,
| "length" input: part.state.input as CurrentToolObject,
| "tool-calls" metadata: (part.state.metadata ?? {}) as CurrentToolObject,
| "content-filter" }
| "error" if (part.state.status === "error")
| "unknown" return {
| undefined, status: "error" as const,
}, input: part.state.input as CurrentToolObject,
] error: { type: "tool_error", message: part.state.error },
metadata: part.state.metadata as CurrentToolObject | undefined,
}
return {
status: "completed" as const,
input: part.state.input as CurrentToolObject,
content: [{ type: "text" as const, text: part.state.output }],
metadata: part.state.metadata as CurrentToolObject,
}
})()
return [
{
id: part.id,
type: "tool" as const,
name: part.tool,
state,
time: {
created: part.state.status === "pending" ? item.info.time.created : part.state.time.start,
ran: part.state.status === "pending" ? undefined : part.state.time.start,
completed:
part.state.status === "completed" || part.state.status === "error" ? part.state.time.end : undefined,
},
},
]
}),
time: item.info.time,
cost: item.info.cost,
tokens: item.info.tokens,
finish: item.info.finish as "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | undefined,
}]
}) })
} }
@@ -438,259 +428,270 @@ describe("server session", () => {
// V2 messages are ordered projections and do not expose V1 assistant parent IDs. // V2 messages are ordered projections and do not expose V1 assistant parent IDs.
describe.skip("V1 assistant parent projections", () => { describe.skip("V1 assistant parent projections", () => {
test("backfills an assistant-only initial page through its user root", async () => { test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))], [
[singleResponse(user)], response(assistants.map((info) => ({ info, parts: [] }))),
) ],
const store = createServerSession(client) [singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(false)
}) })
test("keeps assistant history when its deleted parent cannot be backfilled", async () => { test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>() const missing = Promise.withResolvers<SingleMessageResponse>()
const assistant = assistantMessage("message-2", "message-missing") const assistant = assistantMessage("message-2", "message-missing")
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise]) const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
const store = createServerSession(client) const store = createServerSession(client)
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } })) missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
await loading await loading
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
expect(store.data.message.child).toEqual([assistant]) expect(store.data.message.child).toEqual([assistant])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(false)
}) })
test("drops a cached parent when a forced refresh confirms it was deleted", async () => { test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
const missing = Promise.withResolvers<SingleMessageResponse>() const missing = Promise.withResolvers<SingleMessageResponse>()
const parent = userMessage("message-1") const parent = userMessage("message-1")
const part = textPart(parent.id) const part = textPart(parent.id)
const assistant = assistantMessage("message-2", parent.id) const assistant = assistantMessage("message-2", parent.id)
const client = rootMessageClient( const client = rootMessageClient(
[ [
response([ response([
{ info: parent, parts: [part] }, { info: parent, parts: [part] },
{ info: assistant, parts: [] }, { info: assistant, parts: [] },
]), ]),
response([{ info: assistant, parts: [] }]), response([{ info: assistant, parts: [] }]),
], ],
[missing.promise], [missing.promise],
) )
const store = createServerSession(client) const store = createServerSession(client)
await store.sync("child") await store.sync("child")
const loading = store.sync("child", { force: true }) const loading = store.sync("child", { force: true })
await client.rootRequested(1) await client.rootRequested(1)
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } })) missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
await loading await loading
expect(store.data.message.child).toEqual([assistant]) expect(store.data.message.child).toEqual([assistant])
expect(store.data.part[parent.id]).toBeUndefined() expect(store.data.part[parent.id]).toBeUndefined()
}) })
test("does not let an optimistic user suppress initial root backfill", async () => { test("does not let an optimistic user suppress initial root backfill", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const part = textPart(user.id) const part = textPart(user.id)
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))], [
[singleResponse(user)], response(assistants.map((info) => ({ info, parts: [] }))),
) ],
const store = createServerSession(client) [singleResponse(user)],
store.optimistic.add({ sessionID: "child", message: user, parts: [part] }) )
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
await store.sync("child") await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id }) store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1) expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1) expect(client.rootRequests).toHaveLength(1)
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
}) })
test("backfills the parent of fetched assistants when another user is cached", async () => { test("backfills the parent of fetched assistants when another user is cached", async () => {
const unrelated = userMessage("message-0", { time: { created: 0 } }) const unrelated = userMessage("message-0", { time: { created: 0 } })
const user = userMessage("message-1") const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: unrelated, parts: [] }]), response(assistants.map((info) => ({ info, parts: [] })))], [
[singleResponse(user)], response([{ info: unrelated, parts: [] }]),
) response(assistants.map((info) => ({ info, parts: [] }))),
const store = createServerSession(client) ],
await store.sync("child") [singleResponse(user)],
)
const store = createServerSession(client)
await store.sync("child")
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(client.requests).toHaveLength(2) expect(client.requests).toHaveLength(2)
expect(client.rootRequests).toHaveLength(1) expect(client.rootRequests).toHaveLength(1)
expect(store.data.message.child).toEqual([unrelated, user, ...assistants]) expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
}) })
test("preserves cached history between an injected parent and the page boundary", async () => { test("preserves cached history between an injected parent and the page boundary", async () => {
const user = userMessage("message-1") const user = userMessage("message-1")
const cached = userMessage("message-3", { time: { created: 3 } }) const cached = userMessage("message-3", { time: { created: 3 } })
const assistant = assistantMessage("message-4", user.id) const assistant = assistantMessage("message-4", user.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])], [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(user)], [singleResponse(user)],
) )
const store = createServerSession(client) const store = createServerSession(client)
await store.sync("child") await store.sync("child")
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([user, cached, assistant]) expect(store.data.message.child).toEqual([user, cached, assistant])
}) })
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => { test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const stalePart = textPart(stale.id, { text: "stale" }) const stalePart = textPart(stale.id, { text: "stale" })
const freshPart = { ...stalePart, text: "fresh" } const freshPart = { ...stalePart, text: "fresh" }
const assistant = assistantMessage("message-2", stale.id) const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])], [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [freshPart])], [singleResponse(fresh, [freshPart])],
) )
const store = createServerSession(client) const store = createServerSession(client)
await store.sync("child") await store.sync("child")
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant]) expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([freshPart]) expect(store.data.part[stale.id]).toEqual([freshPart])
}) })
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => { test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } }) const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } } const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" }) const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
const refreshed = { ...confirmed, text: "fresh" } const refreshed = { ...confirmed, text: "fresh" }
const pending = textPart(stale.id, { id: "pending", text: "pending" }) const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id) const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])], [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [refreshed])], [singleResponse(fresh, [refreshed])],
) )
const store = createServerSession(client) const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] }) store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
await store.sync("child") await store.sync("child")
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant]) expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending]) expect(store.data.part[stale.id]).toEqual([refreshed, pending])
}) })
test("uses a parent received by SSE during the replacement load", async () => { test("uses a parent received by SSE during the replacement load", async () => {
const pending = deferredResponse() const pending = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const client = rootMessageClient([pending.promise], []) const client = rootMessageClient([pending.promise], [])
const store = createServerSession(client) const store = createServerSession(client)
const loading = store.sync("child") const loading = store.sync("child")
store.apply({ type: "message.updated", properties: { info: user } }) store.apply({ type: "message.updated", properties: { info: user } })
pending.resolve(response([{ info: assistant, parts: [] }])) pending.resolve(response([{ info: assistant, parts: [] }]))
await loading await loading
expect(client.rootRequests).toEqual([]) expect(client.rootRequests).toEqual([])
expect(store.data.message.child).toEqual([user, assistant]) expect(store.data.message.child).toEqual([user, assistant])
}) })
test("uses a successful retry over events received by a failed backfill attempt", async () => { test("uses a successful retry over events received by a failed backfill attempt", async () => {
const failed = deferredResponse() const failed = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const live = { ...user, agent: "stale" } const live = { ...user, agent: "stale" }
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient( const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))], [
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], response(
) assistants.map((info) => ({ info, parts: [] })),
const store = createServerSession(client, { retry: retryImmediately }) ),
const loading = store.sync("child") ],
await client.rootRequested(1) [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
)
const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child")
await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } }) store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry")) failed.reject(new Error("retry"))
await loading await loading
expect(client.requests).toHaveLength(1) expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(2) expect(client.rootRequests).toHaveLength(2)
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
}) })
test("preserves newer-page events across a failed parent retry", async () => { test("preserves newer-page events across a failed parent retry", async () => {
const failed = deferredResponse() const failed = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const live = { ...assistant, cost: 1 } const live = { ...assistant, cost: 1 }
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [] }])], [response([{ info: assistant, parts: [] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } }) store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry")) failed.reject(new Error("retry"))
await loading await loading
expect(store.data.message.child).toEqual([user, live]) expect(store.data.message.child).toEqual([user, live])
}) })
test("preserves unrelated message events across a failed parent retry", async () => { test("preserves unrelated message events across a failed parent retry", async () => {
const failed = deferredResponse() const failed = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const live = userMessage("message-4", { time: { created: 4 } }) const live = userMessage("message-4", { time: { created: 4 } })
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [] }])], [response([{ info: assistant, parts: [] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
store.apply({ type: "message.updated", properties: { info: live } }) store.apply({ type: "message.updated", properties: { info: live } })
failed.reject(new Error("retry")) failed.reject(new Error("retry"))
await loading await loading
expect(store.data.message.child).toEqual([user, assistant, live]) expect(store.data.message.child).toEqual([user, assistant, live])
}) })
test("preserves newer-page part events across a failed parent retry", async () => { test("preserves newer-page part events across a failed parent retry", async () => {
const failed = deferredResponse() const failed = deferredResponse()
const user = userMessage("message-1") const user = userMessage("message-1")
const assistant = assistantMessage("message-2", user.id) const assistant = assistantMessage("message-2", user.id)
const stale = textPart(assistant.id, { text: "stale" }) const stale = textPart(assistant.id, { text: "stale" })
const live = { ...stale, text: "live" } const live = { ...stale, text: "live" }
const client = rootMessageClient( const client = rootMessageClient(
[response([{ info: assistant, parts: [stale] }])], [response([{ info: assistant, parts: [stale] }])],
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)], [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
) )
const store = createServerSession(client, { retry: retryImmediately }) const store = createServerSession(client, { retry: retryImmediately })
const loading = store.sync("child") const loading = store.sync("child")
await client.rootRequested(1) await client.rootRequested(1)
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
failed.reject(new Error("retry")) failed.reject(new Error("retry"))
await loading await loading
expect(store.data.part[assistant.id]).toEqual([live]) expect(store.data.part[assistant.id]).toEqual([live])
}) })
}) })
test("merges live events into the initial page", async () => { test("merges live events into the initial page", async () => {
+18 -12
View File
@@ -1,7 +1,11 @@
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise" import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types" import type {
Message,
Part,
Todo,
} from "@/types"
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { batch } from "solid-js" import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
@@ -34,16 +38,14 @@ function projectMessageSource(message: Message): SessionMessageInfo[] {
{ id: message.id, type: "user", text: "", time: message.time }, { id: message.id, type: "user", text: "", time: message.time },
] ]
} }
return [ return [{
{ id: message.id,
id: message.id, type: "assistant",
type: "assistant", agent: message.agent ?? message.mode,
agent: message.agent ?? message.mode, model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
model: { id: message.modelID, providerID: message.providerID, variant: message.variant }, content: [],
content: [], time: message.time,
time: message.time, }]
},
]
} }
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
@@ -241,7 +243,11 @@ export function createServerSession(
const indexProjectedMessage = (message: Message) => { const indexProjectedMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? [] const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return if (current.some((item) => item.id === message.id)) return
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)])) setData(
"session_message",
message.sessionID,
reconcile([...current, ...projectMessageSource(message)]),
)
} }
const remember = (session: SessionInfo) => { const remember = (session: SessionInfo) => {
+14 -4
View File
@@ -1,4 +1,9 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types" import type {
Config,
Path,
Project,
ProviderAuthResponse,
} from "@/types"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -159,11 +164,15 @@ export function seedActiveSessionStatuses(
} }
} }
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) { function makeQueryOptionsApi(
scope: ServerScope,
serverAPI: ServerApi,
) {
return { return {
globalConfig: () => loadGlobalConfigQuery(scope), globalConfig: () => loadGlobalConfigQuery(scope),
projects: () => loadProjectsQuery(scope, serverAPI.project), projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI), providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location), path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
@@ -662,12 +671,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
integrationID: server.integrationID, integrationID: server.integrationID,
location: { directory: key }, location: { directory: key },
}) })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length) const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length)
if (!method || method.type !== "oauth") if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`) throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.api.integration.oauth.connect({ const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID, integrationID: server.integrationID,
methodID: method.id, methodID: method.id,
inputs: {},
location: { directory: key }, location: { directory: key },
}) })
platform.openLink(attempt.data.url) platform.openLink(attempt.data.url)
+4 -7
View File
@@ -268,13 +268,10 @@ function createWorkspaceTerminalSession(
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index] const pty = store.all[index]
if (!pty) return if (!pty) return
const data = await sdk.api.pty const data = await sdk.api.pty.create({ location, title: pty.title }).then((result) => result.data).catch((error: unknown) => {
.create({ location, title: pty.title }) console.error("Failed to clone terminal", error)
.then((result) => result.data) return undefined
.catch((error: unknown) => { })
console.error("Failed to clone terminal", error)
return undefined
})
if (!data?.id) return if (!data?.id) return
const active = store.active === pty.id const active = store.active === pty.id
@@ -45,7 +45,13 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { AssistantMessage, Message as MessageType, Part as PartType, ToolPart, UserMessage } from "@/types" import type {
AssistantMessage,
Message as MessageType,
Part as PartType,
ToolPart,
UserMessage,
} from "@/types"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover" import { Popover as KobaltePopover } from "@kobalte/core/popover"
@@ -54,9 +54,9 @@ describe("reviewDiffNeedsLoad", () => {
patch: "@@ -0,0 +1 @@\n+value", patch: "@@ -0,0 +1 @@\n+value",
}), }),
).toBe(false) ).toBe(false)
expect(reviewDiffNeedsLoad({ file: "empty.txt", patch: "", additions: 0, deletions: 0, status: "modified" })).toBe( expect(
false, reviewDiffNeedsLoad({ file: "empty.txt", patch: "", additions: 0, deletions: 0, status: "modified" }),
) ).toBe(false)
}) })
}) })
+5 -1
View File
@@ -1,4 +1,8 @@
import type { EventSubscribeOutput, FileDiffInfo, ProjectListOutput } from "@opencode-ai/client/promise" import type {
EventSubscribeOutput,
FileDiffInfo,
ProjectListOutput,
} from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string } export type Project = Omit<ProjectListOutput[number], "canonical"> & { worktree: string }
+1 -2
View File
@@ -22,6 +22,5 @@
} }
}, },
"include": ["src", "package.json"], "include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"], "exclude": ["dist", "ts-dist"]
"references": [{ "path": "../core" }]
} }
+2 -12
View File
@@ -119,15 +119,7 @@ function installPackage(name) {
try { try {
const result = childProcess.spawnSync( const result = childProcess.spawnSync(
"npm", "npm",
[ ["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
"install",
"--ignore-scripts",
"--no-save",
"--loglevel=error",
"--prefix",
temp,
`${name}@${dependencies[name]}`,
],
{ stdio: "inherit", windowsHide: true }, { stdio: "inherit", windowsHide: true },
) )
if (result.status !== 0) return false if (result.status !== 0) return false
@@ -158,9 +150,7 @@ function main() {
} }
} }
throw new Error( throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`,
)
} }
try { try {
+13 -11
View File
@@ -40,13 +40,15 @@ try {
const token = encodeURIComponent(credential) const token = encodeURIComponent(credential)
const health = await waitForReady(info.url, headers) const health = await waitForReady(info.url, headers)
if (health.pid !== info.pid) throw new Error("Health process does not match registration") if (health.pid !== info.pid) throw new Error("Health process does not match registration")
const tokenHealth = await fetch(new URL(`/api/health?auth_token=${token}`, info.url), { const tokenHealth = await fetch(
signal: AbortSignal.timeout(5_000), new URL(`/api/health?auth_token=${token}`, info.url),
}) { signal: AbortSignal.timeout(5_000) },
)
if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication") if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication")
const tokenOpenApi = await fetch(new URL(`/openapi.json?auth_token=${token}`, info.url), { const tokenOpenApi = await fetch(
signal: AbortSignal.timeout(5_000), new URL(`/openapi.json?auth_token=${token}`, info.url),
}) { signal: AbortSignal.timeout(5_000) },
)
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication") if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation") if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts") const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
@@ -61,8 +63,7 @@ try {
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), { const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
signal: AbortSignal.timeout(5_000), signal: AbortSignal.timeout(5_000),
}) })
if (unauthorizedOpenApi.status !== 401) if (unauthorizedOpenApi.status !== 401) throw new Error("Compiled service exposed application routes without authentication")
throw new Error("Compiled service exposed application routes without authentication")
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), { const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -93,8 +94,7 @@ try {
} finally { } finally {
processes.forEach((process) => process.kill()) processes.forEach((process) => process.kill())
await Promise.all(processes.map((process) => process.exited)) await Promise.all(processes.map((process) => process.exited))
if (failure) if (failure) errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
errors.push(fs.readFile(path.join(root, "data", "opencode", "log", "opencode.log"), "utf8").catch(() => ""))
} }
const output = await Promise.all(errors) const output = await Promise.all(errors)
@@ -154,7 +154,9 @@ async function pluginIDs(url: string, headers: HeadersInit) {
throw new Error("Compiled service returned an invalid plugin list") throw new Error("Compiled service returned an invalid plugin list")
} }
return body.data.flatMap((plugin) => return body.data.flatMap((plugin) =>
typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string" ? [plugin.id] : [], typeof plugin === "object" && plugin !== null && "id" in plugin && typeof plugin.id === "string"
? [plugin.id]
: [],
) )
} }
@@ -50,7 +50,7 @@ const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHo
{ {
integrationID, integrationID,
methodID: method.id, methodID: method.id,
...(server ? { answer: { server } } : {}), inputs: server ? { server } : {},
location, location,
}, },
{ signal }, { signal },
+4 -5
View File
@@ -20,8 +20,7 @@ export default Runtime.handler(
return response.text() return response.text()
}) })
: Bun.file(input.file).text(), : Bun.file(input.file).text(),
catch: (cause) => catch: (cause) => new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
}) })
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text) const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data) const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
@@ -53,9 +52,9 @@ export default Runtime.handler(
return return
} }
if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`)) if (!response.ok) yield* Effect.fail(new Error(`Failed to import session: ${response.statusText}`))
const imported = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ data: Session.Info })))( const imported = yield* Schema.decodeUnknownEffect(
yield* Effect.promise(() => response.text()), Schema.fromJsonString(Schema.Struct({ data: Session.Info })),
) )(yield* Effect.promise(() => response.text()))
process.stdout.write(`Imported session: ${imported.data.id}${EOL}`) process.stdout.write(`Imported session: ${imported.data.id}${EOL}`)
}), }),
) )
@@ -32,7 +32,7 @@ export default Runtime.handler(
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`)) return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
const started = yield* Effect.promise(() => const started = yield* Effect.promise(() =>
client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, location }), client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
) )
const attempt = started.data const attempt = started.data
if (attempt.mode === "code") if (attempt.mode === "code")
+3 -1
View File
@@ -36,6 +36,8 @@ export default Runtime.handler(
const hostname = new URL(endpoint.url).hostname const hostname = new URL(endpoint.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`) process.stderr.write(
` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`,
)
}), }),
) )
+3 -7
View File
@@ -48,16 +48,12 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const attentionSoundPack = kv.attention_sound_pack const attentionSoundPack = kv.attention_sound_pack
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined) const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking = const thinking =
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide") kv.thinking_mode ??
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
return { return {
...(themeName !== undefined || themeMode !== undefined ...(themeName !== undefined || themeMode !== undefined
? { ? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
theme: {
...(themeName === undefined ? {} : { name: themeName }),
...(themeMode === undefined ? {} : { mode: themeMode }),
},
}
: {}), : {}),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }), ...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(plugins.length ? { plugins } : {}), ...(plugins.length ? { plugins } : {}),
+4 -1
View File
@@ -468,7 +468,10 @@ export async function runNonInteractivePrompt(input: Input) {
continue continue
} }
if (event.type === "session.step.failed") { if (event.type === "session.step.failed") {
if (input.compatibility === "v1" && event.data.error.message === "The provider response ended unexpectedly.") { if (
input.compatibility === "v1" &&
event.data.error.message === "The provider response ended unexpectedly."
) {
pendingStep = undefined pendingStep = undefined
v1InvalidOutput = true v1InvalidOutput = true
continue continue
@@ -62,7 +62,10 @@ function managedService(options: EnsureOptions) {
} }
} }
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) { const resolveManaged = Effect.fnUntraced(function* (
options: EnsureOptions,
mismatch: NonNullable<Args["mismatch"]>,
) {
if (mismatch === "replace") return yield* Service.ensure(options) if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined }) if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })

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