mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 17:49:53 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5caf4902d | |||
| 5ff39f379c | |||
| 255d35009b |
@@ -679,6 +679,7 @@
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"modal": "0.9.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -1512,6 +1513,18 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.0.0-alpha.1", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rFbCU83JnN7l3W1nfgCqqme4ZZvTTgsiKQ6FM0l+r0P+o2eJpExcocBUWUIwnDzL76Aca9VhUdWmB2MbUv+Qyg=="],
|
||||
@@ -1720,6 +1733,10 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.15", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg=="],
|
||||
@@ -1814,6 +1831,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -3298,6 +3317,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
@@ -3538,6 +3559,10 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -4570,6 +4595,8 @@
|
||||
|
||||
"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.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
@@ -4796,6 +4823,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -4834,6 +4863,10 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="],
|
||||
@@ -5632,6 +5665,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
|
||||
@@ -6590,6 +6625,8 @@
|
||||
|
||||
"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/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -6734,6 +6771,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
@@ -25,20 +25,8 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
|
||||
const ADAPTER = "gemini"
|
||||
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
||||
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
|
||||
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
|
||||
// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
|
||||
// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
|
||||
const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
if (!/(^|\/)gemini-/i.test(modelID)) return false
|
||||
if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
|
||||
if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
|
||||
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -157,9 +145,6 @@ const GeminiGenerationConfig = Schema.Struct({
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
topP: Schema.optional(Schema.Number),
|
||||
topK: Schema.optional(Schema.Number),
|
||||
frequencyPenalty: Schema.optional(Schema.Number),
|
||||
presencePenalty: Schema.optional(Schema.Number),
|
||||
seed: Schema.optional(Schema.Number),
|
||||
stopSequences: optionalArray(Schema.String),
|
||||
thinkingConfig: Schema.optional(GeminiThinkingConfig),
|
||||
})
|
||||
@@ -217,13 +202,11 @@ interface ParserState {
|
||||
// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules.
|
||||
//
|
||||
// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect:
|
||||
// drop empty root parameter schemas while preserving nested empty objects,
|
||||
// expand type arrays into `anyOf`, derive `nullable: true` from null members,
|
||||
// coerce `const` to `[const]` enum, recurse properties/items, and propagate
|
||||
// drop empty objects, derive `nullable: true` from `type: [..., "null"]`,
|
||||
// coerce `const` to `[const]` enum, recurse properties/items, propagate
|
||||
// only an allowlisted set of keys (description, required, format, type,
|
||||
// nullable, enum, properties, items, allOf, anyOf, oneOf, minLength).
|
||||
// Anything outside the allowlist (e.g. `additionalProperties`, `$ref`) is
|
||||
// silently dropped.
|
||||
// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the
|
||||
// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped.
|
||||
//
|
||||
// Sanitize runs first, then project. The implementation lives in
|
||||
// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other
|
||||
@@ -299,8 +282,6 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const parts: Array<Schema.Schema.Type<typeof GeminiContentPart>> = []
|
||||
// Parallel Gemini 3 calls may carry one signature on the first call; unsigned sibling calls are valid.
|
||||
let hasSignedToolCall = false
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"]))
|
||||
return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"])
|
||||
@@ -313,17 +294,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lowered = lowerToolCall(part)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
thoughtSignature:
|
||||
signature ??
|
||||
(requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
|
||||
? SKIP_THOUGHT_SIGNATURE_VALIDATOR
|
||||
: undefined),
|
||||
})
|
||||
if (signature !== undefined) hasSignedToolCall = true
|
||||
parts.push(lowerToolCall(part))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -417,9 +388,6 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
temperature: generation?.temperature,
|
||||
topP: generation?.topP,
|
||||
topK: generation?.topK,
|
||||
frequencyPenalty: generation?.frequencyPenalty,
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
}
|
||||
|
||||
@@ -90,15 +90,10 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
])
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("system"), id: Schema.optionalKey(Schema.String), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("user"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
content: Schema.Array(OpenResponsesInputContent),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
}),
|
||||
@@ -106,23 +101,19 @@ export const InputItem = Schema.Union([
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
arguments: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call_output"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
call_id: Schema.String,
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
}),
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
type ProviderInputItem = Readonly<Record<string, unknown>> & { readonly type: string; readonly id?: string }
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| ProviderInputItem
|
||||
| {
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
@@ -137,7 +128,7 @@ type OpenResponsesReasoningInput = {
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id"> & { id?: string }
|
||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
@@ -263,11 +254,6 @@ export interface Extension {
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | 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 }
|
||||
@@ -324,17 +310,6 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||
})
|
||||
|
||||
const metadataItemID = (
|
||||
part: { readonly itemId?: string; readonly providerMetadata?: ProviderMetadata },
|
||||
providerMetadataKey: string,
|
||||
) => {
|
||||
if (part.itemId) return part.itemId
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
|
||||
type: "function_call",
|
||||
call_id: part.id,
|
||||
@@ -344,23 +319,26 @@ const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
|
||||
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const itemId = metadataItemID(part, providerMetadataKey)
|
||||
if (!itemId) return undefined
|
||||
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
|
||||
return undefined
|
||||
const encryptedContent =
|
||||
ProviderShared.isRecord(metadata) &&
|
||||
(typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null)
|
||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||
? metadata.reasoningEncryptedContent
|
||||
: undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
id: itemId,
|
||||
id: metadata.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 hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
@@ -419,18 +397,17 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const system: LoweredInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: LoweredInputItem[] = [...system]
|
||||
const store = options.store
|
||||
const store = OpenResponsesOptions.resolve(request).store
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
|
||||
const previous = input.at(-1)
|
||||
if (previous && "role" in previous && previous.role === "user" && Array.isArray(previous.content))
|
||||
if (previous && "role" in previous && previous.role === "user")
|
||||
input[input.length - 1] = {
|
||||
role: "user",
|
||||
content: [...previous.content, { type: "input_text", text: part.text }],
|
||||
@@ -450,24 +427,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
|
||||
const hostedToolItems = new Set<string>()
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
const groups = content.reduce<
|
||||
Array<{ phase: MessagePhase | null | undefined; itemId: string | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
||||
const itemId = metadataItemID(part, providerMetadataKey)
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase && group.itemId === itemId) group.parts.push(part)
|
||||
else groups.push({ phase, itemId, parts: [part] })
|
||||
return groups
|
||||
}, [])
|
||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||
(groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ phase, parts: [part] })
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
)
|
||||
input.push(
|
||||
...groups.map((group) => ({
|
||||
role: "assistant" as const,
|
||||
...(group.itemId === undefined ? {} : { id: group.itemId }),
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
@@ -483,6 +460,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
if (!reasoning) continue
|
||||
if (store !== false) {
|
||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||
reasoningReferences.add(reasoning.id)
|
||||
continue
|
||||
}
|
||||
const existing = reasoningItems[reasoning.id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
@@ -492,7 +474,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
const replay = {
|
||||
type: reasoning.type,
|
||||
id: reasoning.id,
|
||||
summary: reasoning.summary,
|
||||
encrypted_content: reasoning.encrypted_content,
|
||||
}
|
||||
@@ -509,18 +490,16 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const itemID = hostedToolItemID(part, providerMetadataKey)
|
||||
const providerItem = extension.lowerProviderItem?.(part, providerMetadataKey, store)
|
||||
if (providerItem && itemID && !hostedToolItems.has(itemID)) input.push(providerItem)
|
||||
if (!providerItem && store !== false && itemID && !hostedToolItems.has(itemID))
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (!providerItem && store === false && part.result.type === "content") {
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||
})
|
||||
}
|
||||
if (itemID) hostedToolItems.add(itemID)
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
@@ -662,9 +641,9 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const phase = state.messagePhases[id]
|
||||
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata, id)
|
||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta, metadata, id) }, events]
|
||||
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
|
||||
}
|
||||
|
||||
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
@@ -673,13 +652,7 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
||||
}
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(state.lifecycle, events, id, providerMetadata(state, { itemId: id }), id),
|
||||
},
|
||||
events,
|
||||
]
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
@@ -690,14 +663,7 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(
|
||||
state.lifecycle,
|
||||
events,
|
||||
id,
|
||||
event.delta,
|
||||
providerMetadata(state, { itemId: itemID }),
|
||||
itemID,
|
||||
),
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
|
||||
},
|
||||
events,
|
||||
]
|
||||
@@ -739,13 +705,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${item.id}:0`,
|
||||
reasoningMetadata(state, item),
|
||||
item.id,
|
||||
),
|
||||
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
||||
@@ -764,7 +724,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
lifecycle,
|
||||
tools: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id ?? item.id,
|
||||
itemId: item.id,
|
||||
name: item.name ?? "",
|
||||
input: item.arguments ?? "",
|
||||
providerMetadata: metadata,
|
||||
@@ -772,12 +731,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
},
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({
|
||||
id: item.call_id ?? item.id,
|
||||
itemId: item.id,
|
||||
name: item.name ?? "",
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
|
||||
],
|
||||
]
|
||||
}
|
||||
@@ -796,7 +750,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
events,
|
||||
`${event.item_id}:0`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
|
||||
event.item_id,
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
@@ -817,7 +770,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
event.item_id,
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
@@ -829,7 +781,6 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
event.item_id,
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
@@ -865,7 +816,6 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
event.item_id,
|
||||
)
|
||||
: state.lifecycle,
|
||||
reasoningItems: {
|
||||
@@ -920,8 +870,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||
item.id,
|
||||
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
@@ -932,15 +881,9 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
const tools = state.tools[item.id]
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id,
|
||||
itemId: item.id,
|
||||
name: item.name,
|
||||
providerMetadata: metadata,
|
||||
})
|
||||
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
? yield* ToolStream.finish(state.id, tools, item.id)
|
||||
@@ -970,7 +913,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata, item.id),
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||
@@ -978,12 +921,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, itemId: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, itemId: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata, item.id) },
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -38,14 +38,10 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
Schema.StructWithRest(Schema.Struct({ type: Schema.String, id: Schema.optionalKey(Schema.String) }), [
|
||||
Schema.Record(Schema.String, Schema.Unknown),
|
||||
]),
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
@@ -84,25 +80,6 @@ const extension = {
|
||||
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
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
@@ -218,29 +195,23 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
item: HostedToolItem,
|
||||
) {
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
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 providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
itemId: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata: callMetadata,
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
itemId: item.id,
|
||||
name: tool.name,
|
||||
result: yield* hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata: resultMetadata,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
|
||||
@@ -61,57 +61,37 @@ const emptyObjectSchema = (schema: Record<string, unknown>) =>
|
||||
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
|
||||
!schema.additionalProperties
|
||||
|
||||
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
|
||||
const projectNode = (schema: unknown): Record<string, unknown> | undefined => {
|
||||
if (!isRecord(schema)) return undefined
|
||||
if (!nested && emptyObjectSchema(schema)) return undefined
|
||||
const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
|
||||
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined
|
||||
const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false
|
||||
const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf
|
||||
const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined
|
||||
const result = Object.fromEntries(
|
||||
if (emptyObjectSchema(schema)) return undefined
|
||||
return Object.fromEntries(
|
||||
[
|
||||
["description", schema.description],
|
||||
["required", schema.required],
|
||||
["format", schema.format],
|
||||
["type", types ? (types.length === 0 ? "null" : undefined) : schema.type],
|
||||
[
|
||||
"nullable",
|
||||
(Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf
|
||||
? true
|
||||
: undefined,
|
||||
],
|
||||
["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type],
|
||||
["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined],
|
||||
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
|
||||
[
|
||||
"properties",
|
||||
isRecord(schema.properties)
|
||||
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)]))
|
||||
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)]))
|
||||
: undefined,
|
||||
],
|
||||
[
|
||||
"items",
|
||||
Array.isArray(schema.items)
|
||||
? schema.items.map((item) => projectNode(item, true))
|
||||
? schema.items.map(projectNode)
|
||||
: schema.items === undefined
|
||||
? undefined
|
||||
: projectNode(schema.items, true),
|
||||
: projectNode(schema.items),
|
||||
],
|
||||
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined],
|
||||
[
|
||||
"anyOf",
|
||||
anyOfTypes
|
||||
? hasNullAnyOf && anyOfTypes.length === 1
|
||||
? undefined
|
||||
: anyOfTypes.map((item) => projectNode(item, true))
|
||||
: types && types.length > 0
|
||||
? types.map((type) => ({ type }))
|
||||
: undefined,
|
||||
],
|
||||
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
|
||||
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined],
|
||||
["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined],
|
||||
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined],
|
||||
["minLength", schema.minLength],
|
||||
].filter((entry) => entry[1] !== undefined),
|
||||
)
|
||||
return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
|
||||
}
|
||||
|
||||
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
LLMEvent,
|
||||
type FinishReasonDetails,
|
||||
type ProviderMetadata,
|
||||
type ResponseItemID,
|
||||
type Usage,
|
||||
} from "../../schema"
|
||||
import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema"
|
||||
|
||||
export interface State {
|
||||
readonly stepStarted: boolean
|
||||
@@ -20,29 +14,16 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||
return { ...state, stepStarted: true }
|
||||
}
|
||||
|
||||
export const textStart = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
itemId?: ResponseItemID,
|
||||
): State => {
|
||||
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.textStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
|
||||
events.push(LLMEvent.textStart({ id, providerMetadata }))
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const textDelta = (
|
||||
state: State,
|
||||
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 }))
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const started = textStart(state, events, id)
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return started
|
||||
}
|
||||
|
||||
@@ -51,11 +32,10 @@ export const reasoningStart = (
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
itemId?: ResponseItemID,
|
||||
): State => {
|
||||
if (state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.reasoningStart({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
|
||||
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
|
||||
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
||||
}
|
||||
|
||||
@@ -65,10 +45,9 @@ export const reasoningDelta = (
|
||||
id: string,
|
||||
text: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
itemId?: ResponseItemID,
|
||||
): State => {
|
||||
const started = reasoningStart(state, events, id, providerMetadata, itemId)
|
||||
events.push(LLMEvent.reasoningDelta({ id, ...(itemId === undefined ? {} : { itemId }), text, providerMetadata }))
|
||||
const started = reasoningStart(state, events, id, providerMetadata)
|
||||
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
|
||||
return started
|
||||
}
|
||||
|
||||
@@ -77,26 +56,19 @@ export const reasoningEnd = (
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
itemId?: ResponseItemID,
|
||||
): State => {
|
||||
if (!state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.reasoningEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
|
||||
const reasoning = new Set(stepped.reasoning)
|
||||
reasoning.delete(id)
|
||||
return { ...stepped, reasoning }
|
||||
}
|
||||
|
||||
export const textEnd = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
itemId?: ResponseItemID,
|
||||
): State => {
|
||||
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (!state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.textEnd({ id, ...(itemId === undefined ? {} : { itemId }), providerMetadata }))
|
||||
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
||||
const text = new Set(stepped.text)
|
||||
text.delete(id)
|
||||
return { ...stepped, text }
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
type ProviderMetadata,
|
||||
type ResponseItemID,
|
||||
type ToolCall,
|
||||
type ToolInputError,
|
||||
} from "../../schema"
|
||||
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -17,7 +10,6 @@ type StreamKey = string | number
|
||||
* so far, not the parsed object.
|
||||
*/
|
||||
export interface PendingTool extends ToolAccumulator {
|
||||
readonly itemId?: ResponseItemID
|
||||
readonly providerExecuted?: boolean
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
@@ -60,7 +52,6 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
||||
const inputStart = (tool: PendingTool) =>
|
||||
LLMEvent.toolInputStart({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
@@ -69,7 +60,6 @@ const inputStart = (tool: PendingTool) =>
|
||||
const inputDelta = (tool: PendingTool, text: string) =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
text,
|
||||
})
|
||||
@@ -80,7 +70,6 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
Effect.map((input): ToolCall | ToolInputError =>
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
@@ -93,7 +82,6 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
: Effect.succeed(
|
||||
LLMEvent.toolInputError({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
raw,
|
||||
}),
|
||||
@@ -105,15 +93,7 @@ const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||
event.type === "tool-input-error"
|
||||
? [event]
|
||||
: [
|
||||
LLMEvent.toolInputEnd({
|
||||
id: tool.id,
|
||||
...(tool.itemId === undefined ? {} : { itemId: tool.itemId }),
|
||||
name: tool.name,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
event,
|
||||
]
|
||||
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
|
||||
|
||||
/** Store the updated tool and produce the optional public delta event. */
|
||||
const appendTool = <K extends StreamKey>(
|
||||
@@ -168,7 +148,6 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
id,
|
||||
name,
|
||||
input: `${current?.input ?? ""}${delta.text}`,
|
||||
itemId: current?.itemId,
|
||||
providerExecuted: current?.providerExecuted,
|
||||
providerMetadata: current?.providerMetadata,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { ContentBlockID, FinishReason, ProviderMetadata, ResponseItemID, ToolCallID } from "./ids"
|
||||
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
|
||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||
import { ProviderFailureClassification } from "./errors"
|
||||
|
||||
@@ -84,7 +84,6 @@ export type StepStart = Schema.Schema.Type<typeof StepStart>
|
||||
export const TextStart = Schema.Struct({
|
||||
type: Schema.tag("text-start"),
|
||||
id: ContentBlockID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextStart" })
|
||||
export type TextStart = Schema.Schema.Type<typeof TextStart>
|
||||
@@ -93,7 +92,6 @@ export const TextDelta = Schema.Struct({
|
||||
type: Schema.tag("text-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||
@@ -101,7 +99,6 @@ export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||
export const TextEnd = Schema.Struct({
|
||||
type: Schema.tag("text-end"),
|
||||
id: ContentBlockID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.TextEnd" })
|
||||
export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||
@@ -109,7 +106,6 @@ export type TextEnd = Schema.Schema.Type<typeof TextEnd>
|
||||
export const ReasoningStart = Schema.Struct({
|
||||
type: Schema.tag("reasoning-start"),
|
||||
id: ContentBlockID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningStart" })
|
||||
export type ReasoningStart = Schema.Schema.Type<typeof ReasoningStart>
|
||||
@@ -118,7 +114,6 @@ export const ReasoningDelta = Schema.Struct({
|
||||
type: Schema.tag("reasoning-delta"),
|
||||
id: ContentBlockID,
|
||||
text: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||
@@ -126,7 +121,6 @@ export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||
export const ReasoningEnd = Schema.Struct({
|
||||
type: Schema.tag("reasoning-end"),
|
||||
id: ContentBlockID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ReasoningEnd" })
|
||||
export type ReasoningEnd = Schema.Schema.Type<typeof ReasoningEnd>
|
||||
@@ -135,7 +129,6 @@ export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||
@@ -144,7 +137,6 @@ export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||
export const ToolInputDelta = Schema.Struct({
|
||||
type: Schema.tag("tool-input-delta"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
text: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||
@@ -154,7 +146,6 @@ export const ToolInputEnd = Schema.Struct({
|
||||
type: Schema.tag("tool-input-end"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||
@@ -163,7 +154,6 @@ export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||
export const ToolInputError = Schema.Struct({
|
||||
type: Schema.tag("tool-input-error"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
raw: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||
@@ -172,7 +162,6 @@ export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
||||
export const ToolCall = Schema.Struct({
|
||||
type: Schema.tag("tool-call"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -183,7 +172,6 @@ export type ToolCall = Schema.Schema.Type<typeof ToolCall>
|
||||
export const ToolResult = Schema.Struct({
|
||||
type: Schema.tag("tool-result"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
output: Schema.optional(ToolOutput),
|
||||
@@ -195,7 +183,6 @@ export type ToolResult = Schema.Schema.Type<typeof ToolResult>
|
||||
export const ToolError = Schema.Struct({
|
||||
type: Schema.tag("tool-error"),
|
||||
id: ToolCallID,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
error: Schema.optional(Schema.Defect()),
|
||||
@@ -347,14 +334,12 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
|
||||
interface ContentAssembly {
|
||||
readonly contentIndex: number
|
||||
readonly text: string
|
||||
readonly itemId?: ResponseItemID
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
|
||||
interface ToolInputAssembly {
|
||||
readonly name: string
|
||||
readonly text: string
|
||||
readonly itemId?: ResponseItemID
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
|
||||
@@ -400,27 +385,11 @@ const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
}
|
||||
}
|
||||
|
||||
const textContent = (
|
||||
text: string,
|
||||
itemId: ResponseItemID | undefined,
|
||||
providerMetadata: ProviderMetadata | undefined,
|
||||
): ContentPart => ({
|
||||
type: "text",
|
||||
text,
|
||||
...(itemId === undefined ? {} : { itemId }),
|
||||
...(providerMetadata === undefined ? {} : { providerMetadata }),
|
||||
})
|
||||
const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||
providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata }
|
||||
|
||||
const reasoningContent = (
|
||||
text: string,
|
||||
itemId: ResponseItemID | undefined,
|
||||
providerMetadata: ProviderMetadata | undefined,
|
||||
): ContentPart => ({
|
||||
type: "reasoning",
|
||||
text,
|
||||
...(itemId === undefined ? {} : { itemId }),
|
||||
...(providerMetadata === undefined ? {} : { providerMetadata }),
|
||||
})
|
||||
const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||
providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata }
|
||||
|
||||
const contentWith = (state: ResponseState, content: ReadonlyArray<ContentPart>): ResponseState => ({
|
||||
...state,
|
||||
@@ -435,32 +404,26 @@ const replaceContent = (state: ResponseState, index: number, part: ContentPart)
|
||||
state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)),
|
||||
)
|
||||
|
||||
const ensureText = (
|
||||
state: ResponseState,
|
||||
id: string,
|
||||
itemId?: ResponseItemID,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
): ResponseState => {
|
||||
const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
|
||||
if (state.textParts[id]) return state
|
||||
return {
|
||||
...appendContent(state, textContent("", itemId, providerMetadata)),
|
||||
...appendContent(state, textContent("", providerMetadata)),
|
||||
textParts: {
|
||||
...state.textParts,
|
||||
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
|
||||
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => {
|
||||
const started = ensureText(state, event.id, event.itemId, event.providerMetadata)
|
||||
const started = ensureText(state, event.id, event.providerMetadata)
|
||||
const current = started.textParts[event.id]
|
||||
if (!current) return started
|
||||
const text = current.text + event.text
|
||||
const itemId = event.itemId ?? current.itemId
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
return {
|
||||
...replaceContent(started, current.contentIndex, textContent(text, itemId, providerMetadata)),
|
||||
textParts: { ...started.textParts, [event.id]: { ...current, text, itemId, providerMetadata } },
|
||||
...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)),
|
||||
textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,39 +431,32 @@ const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => {
|
||||
const current = state.textParts[event.id]
|
||||
if (!current) return state
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
const itemId = event.itemId ?? current.itemId
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, textContent(current.text, itemId, providerMetadata)),
|
||||
textParts: { ...state.textParts, [event.id]: { ...current, itemId, providerMetadata } },
|
||||
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
|
||||
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
const ensureReasoning = (
|
||||
state: ResponseState,
|
||||
id: string,
|
||||
itemId?: ResponseItemID,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
): ResponseState => {
|
||||
const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => {
|
||||
if (state.reasoningParts[id]) return state
|
||||
return {
|
||||
...appendContent(state, reasoningContent("", itemId, providerMetadata)),
|
||||
...appendContent(state, reasoningContent("", providerMetadata)),
|
||||
reasoningParts: {
|
||||
...state.reasoningParts,
|
||||
[id]: { contentIndex: state.message.content.length, text: "", itemId, providerMetadata },
|
||||
[id]: { contentIndex: state.message.content.length, text: "", providerMetadata },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => {
|
||||
const started = ensureReasoning(state, event.id, event.itemId, event.providerMetadata)
|
||||
const started = ensureReasoning(state, event.id, event.providerMetadata)
|
||||
const current = started.reasoningParts[event.id]
|
||||
if (!current) return started
|
||||
const text = current.text + event.text
|
||||
const itemId = event.itemId ?? current.itemId
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
return {
|
||||
...replaceContent(started, current.contentIndex, reasoningContent(text, itemId, providerMetadata)),
|
||||
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, itemId, providerMetadata } },
|
||||
...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)),
|
||||
reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,10 +464,9 @@ const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): Response
|
||||
const current = state.reasoningParts[event.id]
|
||||
if (!current) return state
|
||||
const providerMetadata = event.providerMetadata ?? current.providerMetadata
|
||||
const itemId = event.itemId ?? current.itemId
|
||||
return {
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(current.text, itemId, providerMetadata)),
|
||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, itemId, providerMetadata } },
|
||||
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
|
||||
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +474,7 @@ const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): Resp
|
||||
...state,
|
||||
toolInputs: {
|
||||
...state.toolInputs,
|
||||
[event.id]: { name: event.name, text: "", itemId: event.itemId, providerMetadata: event.providerMetadata },
|
||||
[event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -540,7 +495,6 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
|
||||
[event.id]: {
|
||||
...current,
|
||||
name: event.name,
|
||||
itemId: event.itemId ?? current.itemId,
|
||||
providerMetadata: event.providerMetadata ?? current.providerMetadata,
|
||||
},
|
||||
},
|
||||
@@ -550,7 +504,6 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response
|
||||
const toolCallContent = (event: ToolCall): ContentPart =>
|
||||
ToolCallPart.make({
|
||||
id: event.id,
|
||||
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
|
||||
name: event.name,
|
||||
input: event.input,
|
||||
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
||||
@@ -560,7 +513,6 @@ const toolCallContent = (event: ToolCall): ContentPart =>
|
||||
const toolResultContent = (event: ToolResult): ContentPart =>
|
||||
ToolResultPart.make({
|
||||
id: event.id,
|
||||
...(event.itemId === undefined ? {} : { itemId: event.itemId }),
|
||||
name: event.name,
|
||||
result: event.result,
|
||||
...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }),
|
||||
@@ -576,13 +528,13 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
||||
const next = appendEvent(state, event)
|
||||
switch (event.type) {
|
||||
case "text-start":
|
||||
return ensureText(next, event.id, event.itemId, event.providerMetadata)
|
||||
return ensureText(next, event.id, event.providerMetadata)
|
||||
case "text-delta":
|
||||
return reduceTextDelta(next, event)
|
||||
case "text-end":
|
||||
return reduceTextEnd(next, event)
|
||||
case "reasoning-start":
|
||||
return ensureReasoning(next, event.id, event.itemId, event.providerMetadata)
|
||||
return ensureReasoning(next, event.id, event.providerMetadata)
|
||||
case "reasoning-delta":
|
||||
return reduceReasoningDelta(next, event)
|
||||
case "reasoning-end":
|
||||
|
||||
@@ -21,9 +21,6 @@ export type ProviderID = typeof ProviderID.Type
|
||||
export const ResponseID = Schema.String
|
||||
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 type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { JsonSchema, MessageRole, ProviderMetadata, ResponseItemID } from "./ids"
|
||||
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
||||
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
|
||||
import { isRecord } from "../utils/record"
|
||||
|
||||
@@ -25,7 +25,6 @@ export const SystemPart = Object.assign(systemPartSchema, {
|
||||
export const TextPart = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
@@ -122,7 +121,6 @@ export const ToolCallPart = Object.assign(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("tool-call"),
|
||||
id: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -140,7 +138,6 @@ export const ToolResultPart = Object.assign(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("tool-result"),
|
||||
id: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
name: Schema.String,
|
||||
result: ToolResultValue,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
@@ -157,7 +154,6 @@ export const ToolResultPart = Object.assign(
|
||||
): ToolResultPart => ({
|
||||
type: "tool-result",
|
||||
id: input.id,
|
||||
...(input.itemId === undefined ? {} : { itemId: input.itemId }),
|
||||
name: input.name,
|
||||
result: ToolResultValue.make(input.result, input.resultType),
|
||||
providerExecuted: input.providerExecuted,
|
||||
@@ -172,7 +168,6 @@ export type ToolResultPart = Schema.Schema.Type<typeof ToolResultPart>
|
||||
export const ReasoningPart = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
itemId: Schema.optional(ResponseItemID),
|
||||
encrypted: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
@@ -186,7 +181,7 @@ export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, Tool
|
||||
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
|
||||
|
||||
export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
id: Schema.optional(ResponseItemID),
|
||||
id: Schema.optional(Schema.String),
|
||||
role: MessageRole,
|
||||
content: Schema.Array(ContentPart),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
|
||||
@@ -79,7 +79,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
result: settlement.result,
|
||||
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
|
||||
providerMetadata: call.providerMetadata,
|
||||
}),
|
||||
]
|
||||
: [
|
||||
@@ -88,7 +88,7 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
|
||||
name: call.name,
|
||||
result: settlement.result,
|
||||
output: settlement.output,
|
||||
...(call.providerMetadata === undefined ? {} : { providerMetadata: call.providerMetadata }),
|
||||
providerMetadata: call.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
+6
-11
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
+1
-1
@@ -44,7 +44,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
ToolResultPart,
|
||||
type ToolResultValue,
|
||||
type Usage,
|
||||
} from "../../src/schema"
|
||||
import { type Tools, toDefinitions } from "../../src/tool"
|
||||
@@ -60,10 +61,9 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
|
||||
...dispatched.map(([call, dispatched]) =>
|
||||
Message.tool({
|
||||
id: call.id,
|
||||
itemId: dispatched.events.find(LLMEvent.is.toolResult)?.itemId,
|
||||
name: call.name,
|
||||
result: dispatched.result,
|
||||
providerMetadata: dispatched.events.find(LLMEvent.is.toolResult)?.providerMetadata,
|
||||
providerMetadata: call.providerMetadata,
|
||||
}),
|
||||
),
|
||||
],
|
||||
@@ -89,15 +89,9 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
||||
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text, event.itemId)
|
||||
appendText(assistantContent, event.type === "text-delta" ? "text" : "reasoning", event.text)
|
||||
} else if (event.type === "text-end" || event.type === "reasoning-end") {
|
||||
appendText(
|
||||
assistantContent,
|
||||
event.type === "text-end" ? "text" : "reasoning",
|
||||
"",
|
||||
event.itemId,
|
||||
event.providerMetadata,
|
||||
)
|
||||
appendText(assistantContent, event.type === "text-end" ? "text" : "reasoning", "", event.providerMetadata)
|
||||
} else if (event.type === "tool-call") {
|
||||
assistantContent.push(event)
|
||||
if (!event.providerExecuted) toolCalls.push(event)
|
||||
@@ -105,7 +99,6 @@ const stepState = (events: ReadonlyArray<LLMEvent>) => {
|
||||
assistantContent.push(
|
||||
ToolResultPart.make({
|
||||
id: event.id,
|
||||
itemId: event.itemId,
|
||||
name: event.name,
|
||||
result: event.result,
|
||||
providerExecuted: true,
|
||||
@@ -125,7 +118,6 @@ const appendText = (
|
||||
content: ContentPart[],
|
||||
type: "text" | "reasoning",
|
||||
text: string,
|
||||
itemId?: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
) => {
|
||||
const last = content.at(-1)
|
||||
@@ -133,12 +125,11 @@ const appendText = (
|
||||
content[content.length - 1] = {
|
||||
...last,
|
||||
text: `${last.text}${text}`,
|
||||
itemId: itemId ?? last.itemId,
|
||||
providerMetadata: providerMetadata ?? last.providerMetadata,
|
||||
}
|
||||
return
|
||||
}
|
||||
content.push({ type, text, itemId, providerMetadata })
|
||||
content.push({ type, text, providerMetadata })
|
||||
}
|
||||
|
||||
const addUsage = (left: Usage | undefined, right: Usage | undefined): Usage | undefined => {
|
||||
|
||||
@@ -16,13 +16,6 @@ const model = Gemini.route
|
||||
})
|
||||
.model({ id: "gemini-2.5-flash" })
|
||||
|
||||
const gemini3 = Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemini-3-flash-preview" })
|
||||
|
||||
const request = LLM.request({
|
||||
id: "req_1",
|
||||
model,
|
||||
@@ -93,39 +86,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards standard Gemini generation options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
generation: {
|
||||
maxTokens: 40,
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
topK: 12,
|
||||
frequencyPenalty: 0.3,
|
||||
presencePenalty: 0.4,
|
||||
seed: 42,
|
||||
stop: ["done"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.generationConfig).toEqual({
|
||||
maxOutputTokens: 40,
|
||||
temperature: 0.2,
|
||||
topP: 0.8,
|
||||
topK: 12,
|
||||
frequencyPenalty: 0.3,
|
||||
presencePenalty: 0.4,
|
||||
seed: 42,
|
||||
stopSequences: ["done"],
|
||||
thinkingConfig: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -390,100 +350,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nested empty object tool schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Use the tool.",
|
||||
tools: [
|
||||
{
|
||||
name: "configure",
|
||||
description: "Configure the operation",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["options"],
|
||||
properties: {
|
||||
options: { type: "object", description: "Optional provider settings", properties: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: "configure",
|
||||
description: "Configure the operation",
|
||||
parameters: {
|
||||
type: "object",
|
||||
required: ["options"],
|
||||
properties: {
|
||||
options: { type: "object", description: "Optional provider settings", properties: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Gemini type arrays without narrowing their allowed values", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Use the tool.",
|
||||
tools: [
|
||||
{
|
||||
name: "filter",
|
||||
description: "Filter values",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: ["number", "string"], description: "Status filter" },
|
||||
maybe: { type: ["string", "null"] },
|
||||
nothing: { type: ["null"] },
|
||||
explicit: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
choice: { anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parameters).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
status: {
|
||||
description: "Status filter",
|
||||
anyOf: [{ type: "number" }, { type: "string" }],
|
||||
},
|
||||
maybe: {
|
||||
nullable: true,
|
||||
anyOf: [{ type: "string" }],
|
||||
},
|
||||
nothing: {
|
||||
type: "null",
|
||||
},
|
||||
explicit: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
},
|
||||
choice: {
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text, reasoning, and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -670,44 +536,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "tool_0", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("Open Responses-compatible route", () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(prepared.body).toMatchObject({
|
||||
expect(prepared.body).toEqual({
|
||||
model: "example-model",
|
||||
input: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
@@ -53,8 +53,6 @@ describe("Open Responses-compatible route", () => {
|
||||
],
|
||||
stream: true,
|
||||
})
|
||||
expect(prepared.body.input[0]).not.toHaveProperty("id")
|
||||
expect(prepared.body.input[1]).not.toHaveProperty("id")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -69,9 +69,6 @@ describe("OpenAI Responses route", () => {
|
||||
stream: true,
|
||||
max_output_tokens: 20,
|
||||
temperature: 0,
|
||||
tool_choice: undefined,
|
||||
tools: undefined,
|
||||
top_p: undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -332,7 +329,7 @@ describe("OpenAI Responses route", () => {
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/",
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
apiKey: "azure-key",
|
||||
headers: { authorization: "Bearer stale" },
|
||||
}).responses("gpt-4.1-mini"),
|
||||
@@ -413,7 +410,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
expect(prepared.body).toEqual({
|
||||
model: "gpt-4.1-mini",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
|
||||
@@ -428,60 +425,6 @@ describe("OpenAI Responses route", () => {
|
||||
tools: 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("preserves opaque assistant item ids without assigning ids to function items", () =>
|
||||
Effect.gen(function* () {
|
||||
const canonical = LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Calling.", itemId: "plain-text" },
|
||||
ToolCallPart.make({ id: "call_1", itemId: "plain-call", name: "lookup", input: {} }),
|
||||
]),
|
||||
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",
|
||||
"plain-call",
|
||||
])
|
||||
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", call_id: "call_1", name: "lookup", arguments: "{}" },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '"done"' },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -921,21 +864,9 @@ describe("OpenAI Responses route", () => {
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{
|
||||
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-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{
|
||||
type: "step-finish",
|
||||
@@ -992,20 +923,17 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
itemId: "msg_commentary",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
itemId: "msg_final",
|
||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||
providerMetadata: { openai: { phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
itemId: "msg_null",
|
||||
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
||||
providerMetadata: { openai: { phase: null } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1013,19 +941,16 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
id: "msg_commentary",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
id: "msg_final",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
id: "msg_null",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
@@ -1118,24 +1043,12 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{ type: "text-start", id: "msg_1", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{
|
||||
type: "text-delta",
|
||||
id: "msg_1",
|
||||
itemId: "msg_1",
|
||||
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" } } },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_2" },
|
||||
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||
{ type: "text-end", id: "msg_2" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1155,15 +1068,9 @@ describe("OpenAI Responses route", () => {
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "rs_1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_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: "reasoning-start", id: "rs_1" },
|
||||
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "rs_1" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
@@ -1172,8 +1079,8 @@ describe("OpenAI Responses route", () => {
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "thinking", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "text", text: "Hello", itemId: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hello" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1204,7 +1111,6 @@ describe("OpenAI Responses route", () => {
|
||||
expect.objectContaining({
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
itemId: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
}),
|
||||
)
|
||||
@@ -1245,34 +1151,19 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
itemId: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{
|
||||
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-delta", id: "rs_1:0", text: "First" },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
itemId: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{
|
||||
type: "reasoning-delta",
|
||||
id: "rs_1:1",
|
||||
itemId: "rs_1",
|
||||
text: "Second",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
itemId: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
@@ -1310,8 +1201,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{ type: "reasoning-end", id: "rs_1:0", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:1", itemId: "rs_1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1359,7 +1250,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
})
|
||||
expect(body.input[1]).toHaveProperty("id", "rs_1")
|
||||
expect(body.input[1]).not.toHaveProperty("id")
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||
@@ -1406,7 +1297,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||
},
|
||||
@@ -1415,7 +1305,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays complete stored reasoning items with their id", () =>
|
||||
it.effect("references stored reasoning items by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1433,14 +1323,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
encrypted_content: undefined,
|
||||
},
|
||||
])
|
||||
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1549,7 +1432,6 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
@@ -1629,7 +1511,6 @@ describe("OpenAI Responses route", () => {
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: undefined,
|
||||
cacheWriteInputTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
|
||||
@@ -1640,35 +1521,30 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
text: '{"query"',
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
@@ -1688,17 +1564,6 @@ describe("OpenAI Responses route", () => {
|
||||
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" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1731,7 +1596,6 @@ describe("OpenAI Responses route", () => {
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
|
||||
type: "tool-input-error",
|
||||
id: "call_1",
|
||||
itemId: "item_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
@@ -1788,7 +1652,6 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "ws_1",
|
||||
itemId: "ws_1",
|
||||
name: "web_search",
|
||||
input: { type: "search", query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
@@ -1797,35 +1660,11 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
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 } },
|
||||
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" } },
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1903,7 +1742,6 @@ describe("OpenAI Responses route", () => {
|
||||
expect(toolCall).toEqual({
|
||||
type: "tool-call",
|
||||
id: "ci_1",
|
||||
itemId: "ci_1",
|
||||
name: "code_interpreter",
|
||||
input: { code: "print(1+1)", container_id: "cnt_xyz" },
|
||||
providerExecuted: true,
|
||||
@@ -1913,12 +1751,10 @@ describe("OpenAI Responses route", () => {
|
||||
expect(toolResult).toEqual({
|
||||
type: "tool-result",
|
||||
id: "ci_1",
|
||||
itemId: "ci_1",
|
||||
name: "code_interpreter",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ci_1", item } },
|
||||
output: undefined,
|
||||
providerMetadata: { openai: { itemId: "ci_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -49,43 +49,6 @@ describe("LLMResponse reducer", () => {
|
||||
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", () => {
|
||||
const state = reduce([
|
||||
LLMEvent.textStart({ id: "t1" }),
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("LLMClient tools", () => {
|
||||
expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { 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.events).toMatchObject([
|
||||
expect(dispatched.events).toEqual([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_projected",
|
||||
name: "projected",
|
||||
@@ -180,7 +180,6 @@ describe("LLMClient tools", () => {
|
||||
output: { structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] },
|
||||
}),
|
||||
])
|
||||
expect(dispatched.events[0]?.itemId).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -198,7 +197,7 @@ describe("LLMClient tools", () => {
|
||||
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||
)
|
||||
|
||||
expect(dispatched.events).toMatchObject([
|
||||
expect(dispatched.events).toEqual([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_1",
|
||||
name: "tool",
|
||||
@@ -207,13 +206,12 @@ describe("LLMClient tools", () => {
|
||||
providerMetadata,
|
||||
}),
|
||||
])
|
||||
expect(dispatched.events[0]?.itemId).toBeUndefined()
|
||||
|
||||
const failed = yield* ToolRuntime.dispatch(
|
||||
{},
|
||||
LLMEvent.toolCall({ id: "call_2", itemId: "fc_failed", name: "missing", input: {}, providerMetadata }),
|
||||
LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
|
||||
)
|
||||
expect(failed.events).toMatchObject([
|
||||
expect(failed.events).toEqual([
|
||||
LLMEvent.toolError({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
@@ -227,27 +225,6 @@ describe("LLMClient tools", () => {
|
||||
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()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -460,7 +437,7 @@ describe("LLMClient tools", () => {
|
||||
)
|
||||
|
||||
expect(dispatched.result).toEqual(callerOwned)
|
||||
expect(dispatched.events).toMatchObject([
|
||||
expect(dispatched.events).toEqual([
|
||||
LLMEvent.toolResult({
|
||||
id: "call_1",
|
||||
name: "eventful",
|
||||
@@ -468,7 +445,6 @@ describe("LLMClient tools", () => {
|
||||
output: { structured: { ok: true }, content: [] },
|
||||
}),
|
||||
])
|
||||
expect(dispatched.events[0]?.itemId).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"exports": {
|
||||
"./environment": "./src/environment/index.ts",
|
||||
"./testing/environment-conformance": "./test/lib/environment-conformance.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./instructions": "./src/instructions/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
|
||||
@@ -116,11 +116,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})
|
||||
const currentAssistantMessageID = () =>
|
||||
stepStarted ? Effect.succeed(assistantMessageID) : Effect.die(new Error("Tool event before assistant step start"))
|
||||
const providerState = (metadata: ProviderMetadata | undefined, itemId?: string) => {
|
||||
const state = metadata?.[input.providerMetadataKey]
|
||||
if (itemId === undefined) return state
|
||||
return { ...(typeof state === "object" && state !== null && !Array.isArray(state) ? state : {}), itemId }
|
||||
}
|
||||
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
|
||||
const fragments = (
|
||||
name: string,
|
||||
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
|
||||
@@ -344,7 +340,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
return
|
||||
case "text-start":
|
||||
outputStarted = true
|
||||
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata, event.itemId))
|
||||
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
|
||||
yield* bus.publish(SessionEvent.Text.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
@@ -352,11 +348,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})
|
||||
return
|
||||
case "text-delta":
|
||||
const deltaTextOrdinal = yield* text.append(
|
||||
event.id,
|
||||
event.text,
|
||||
providerState(event.providerMetadata, event.itemId),
|
||||
)
|
||||
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
|
||||
yield* bus.publish(SessionEvent.Text.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* currentAssistantMessageID(),
|
||||
@@ -365,26 +357,23 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})
|
||||
return
|
||||
case "text-end":
|
||||
yield* text.end(event.id, providerState(event.providerMetadata, event.itemId))
|
||||
yield* text.end(event.id, providerState(event.providerMetadata))
|
||||
return
|
||||
case "reasoning-start":
|
||||
outputStarted = true
|
||||
const startedReasoningOrdinal = yield* reasoning.start(
|
||||
event.id,
|
||||
providerState(event.providerMetadata, event.itemId),
|
||||
)
|
||||
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
|
||||
yield* bus.publish(SessionEvent.Reasoning.Started, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* startAssistant(),
|
||||
ordinal: startedReasoningOrdinal,
|
||||
state: providerState(event.providerMetadata, event.itemId),
|
||||
state: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
case "reasoning-delta":
|
||||
const deltaReasoningOrdinal = yield* reasoning.append(
|
||||
event.id,
|
||||
event.text,
|
||||
providerState(event.providerMetadata, event.itemId),
|
||||
providerState(event.providerMetadata),
|
||||
)
|
||||
yield* bus.publish(SessionEvent.Reasoning.Delta, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -394,7 +383,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})
|
||||
return
|
||||
case "reasoning-end":
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata, event.itemId))
|
||||
yield* reasoning.end(event.id, providerState(event.providerMetadata))
|
||||
return
|
||||
case "tool-input-start":
|
||||
outputStarted = true
|
||||
@@ -438,7 +427,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
id: event.id,
|
||||
input: asRecord(event.input),
|
||||
executed: tool.providerExecuted,
|
||||
state: providerState(event.providerMetadata, event.itemId),
|
||||
state: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -456,7 +445,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}
|
||||
tool.settled = true
|
||||
const executed = event.providerExecuted === true || tool.providerExecuted
|
||||
const resultState = providerState(event.providerMetadata, event.itemId)
|
||||
const resultState = providerState(event.providerMetadata)
|
||||
if (event.result.type === "error") {
|
||||
yield* bus.publish(SessionEvent.Tool.Failed, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -496,7 +485,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
: { type: "tool.execution", message: event.message },
|
||||
...failureSnapshot(tool),
|
||||
executed: tool.providerExecuted,
|
||||
resultState: providerState(event.providerMetadata, event.itemId),
|
||||
resultState: providerState(event.providerMetadata),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -521,7 +510,8 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
|
||||
const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) {
|
||||
const tool = tools.get(id)
|
||||
if (!tool?.called || tool.settled) return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
|
||||
if (!tool?.called || tool.settled)
|
||||
return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`))
|
||||
tool.progress = update
|
||||
yield* bus.publish(SessionEvent.Tool.Progress, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -532,7 +522,11 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
})
|
||||
|
||||
/** Publishes one canonical terminal event for a locally executed tool call. */
|
||||
const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) {
|
||||
const toolExecution = Effect.fnUntraced(function* (
|
||||
id: string,
|
||||
name: string,
|
||||
result: Tool.Result,
|
||||
) {
|
||||
const tool = tools.get(id)
|
||||
if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`))
|
||||
if (tool.name !== name)
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Message,
|
||||
ToolCallPart,
|
||||
ToolResultPart,
|
||||
type ContentPart,
|
||||
type ProviderMetadata,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { Model } from "../../model"
|
||||
import { SessionMessage } from "../message"
|
||||
@@ -72,46 +66,27 @@ const providerMetadata = (
|
||||
state: Record<string, unknown> | undefined,
|
||||
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
|
||||
|
||||
const responseItemID = (state: Record<string, unknown> | undefined) =>
|
||||
typeof state?.itemId === "string" ? state.itemId : undefined
|
||||
|
||||
const portableProviderState = (state: Record<string, unknown> | undefined) => {
|
||||
if (state === undefined || !("itemId" in state)) return state
|
||||
const { itemId: _itemId, ...portable } = state
|
||||
return portable
|
||||
}
|
||||
|
||||
const toolInput = (tool: SessionMessage.AssistantTool) =>
|
||||
tool.state.status === "streaming"
|
||||
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
|
||||
: tool.state.input
|
||||
|
||||
const toolCall = (
|
||||
tool: SessionMessage.AssistantTool,
|
||||
itemId: string | undefined,
|
||||
providerMetadata: ProviderMetadata | undefined,
|
||||
): ContentPart =>
|
||||
const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined): ContentPart =>
|
||||
ToolCallPart.make({
|
||||
id: tool.id,
|
||||
...(itemId === undefined ? {} : { itemId }),
|
||||
name: tool.name,
|
||||
input: toolInput(tool),
|
||||
providerExecuted: tool.executed,
|
||||
providerMetadata,
|
||||
})
|
||||
|
||||
const toolResult = (
|
||||
tool: SessionMessage.AssistantTool,
|
||||
itemId: string | undefined,
|
||||
providerMetadata: ProviderMetadata | undefined,
|
||||
) => {
|
||||
const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: ProviderMetadata | undefined) => {
|
||||
if (tool.state.status === "completed") {
|
||||
// TODO: Materialize remote and managed URIs before provider-history lowering.
|
||||
const content = tool.state.content
|
||||
const single = content.length === 1 ? content[0] : undefined
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
...(itemId === undefined ? {} : { itemId }),
|
||||
name: tool.name,
|
||||
result:
|
||||
single?.type === "text"
|
||||
@@ -124,7 +99,6 @@ const toolResult = (
|
||||
if (tool.state.status === "error") {
|
||||
return ToolResultPart.make({
|
||||
id: tool.id,
|
||||
...(itemId === undefined ? {} : { itemId }),
|
||||
name: tool.name,
|
||||
result: { error: tool.state.error, content: tool.state.content ?? [] },
|
||||
resultType: "error",
|
||||
@@ -144,13 +118,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
{
|
||||
type: "text",
|
||||
text: item.text,
|
||||
itemId: reuseProviderMetadata ? responseItemID(item.state) : undefined,
|
||||
providerMetadata: sameProvider
|
||||
? providerMetadata(
|
||||
providerMetadataKey,
|
||||
reuseProviderMetadata ? item.state : portableProviderState(item.state),
|
||||
)
|
||||
: undefined,
|
||||
providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined,
|
||||
},
|
||||
]
|
||||
if (item.type === "reasoning")
|
||||
@@ -159,7 +127,6 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
{
|
||||
type: "reasoning",
|
||||
text: item.text,
|
||||
itemId: responseItemID(item.state),
|
||||
providerMetadata: providerMetadata(providerMetadataKey, item.state),
|
||||
},
|
||||
]
|
||||
@@ -171,7 +138,6 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
(sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error"))
|
||||
const call = toolCall(
|
||||
item,
|
||||
reuseToolProviderMetadata ? responseItemID(item.providerState) : undefined,
|
||||
reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
|
||||
)
|
||||
if (item.executed !== true) return [call]
|
||||
@@ -179,11 +145,6 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
// replay must survive a model switch within the same provider.
|
||||
const result = toolResult(
|
||||
item,
|
||||
reuseToolProviderMetadata
|
||||
? responseItemID(item.providerResultState ?? (item.executed === true ? item.providerState : undefined))
|
||||
: sameProvider && item.executed === true
|
||||
? responseItemID(item.providerResultState)
|
||||
: undefined,
|
||||
reuseToolProviderMetadata
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
: sameProvider && item.executed === true && item.providerResultState !== undefined
|
||||
@@ -202,8 +163,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
.map((item) =>
|
||||
toolResult(
|
||||
item,
|
||||
responseItemID(item.providerResultState) ?? `fco_${item.id}`,
|
||||
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerResultState) : undefined,
|
||||
reuseProviderMetadata
|
||||
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.filter((message) => message !== undefined)
|
||||
@@ -242,7 +204,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
case "skill":
|
||||
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
|
||||
case "system":
|
||||
return [Message.make({ id: message.id, role: "system", content: message.text })]
|
||||
return [Message.system(message.text)]
|
||||
case "shell":
|
||||
return [
|
||||
Message.make({
|
||||
|
||||
@@ -118,12 +118,13 @@ const layer = Layer.effect(
|
||||
yield* hooks.trigger("tool", "execute.after", afterEvent)
|
||||
return yield* afterEvent.error
|
||||
}
|
||||
const content = yield* normalizeImages(execution.value.content)
|
||||
const afterEvent: PluginHooks.Domains["tool"]["execute.after"] = {
|
||||
...base,
|
||||
status: "completed",
|
||||
result: {
|
||||
...(execution.value.output === undefined ? {} : { output: execution.value.output }),
|
||||
content: execution.value.content,
|
||||
content: content.length > 0 ? content : execution.value.content,
|
||||
...(execution.value.metadata === undefined ? {} : { metadata: execution.value.metadata }),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -110,13 +110,7 @@ describe("toLLMMessages", () => {
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
|
||||
expect(messages[0]).toEqual(
|
||||
Message.make({
|
||||
id: "msg_system",
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "Updated context\n\nOther context" }],
|
||||
}),
|
||||
)
|
||||
expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
|
||||
expect(messages[1]).toEqual(
|
||||
Message.make({
|
||||
id: id("user"),
|
||||
@@ -482,7 +476,6 @@ Recent work
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "completed",
|
||||
itemId: "fco_completed",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
@@ -520,7 +513,6 @@ Recent work
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Think",
|
||||
itemId: "rs_1",
|
||||
providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
@@ -610,7 +602,6 @@ Recent work
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-completed",
|
||||
itemId: "call_completed",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
@@ -619,7 +610,6 @@ Recent work
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-completed",
|
||||
itemId: "result_completed",
|
||||
name: "web_search",
|
||||
result: { type: "text", value: '{"found":true}' },
|
||||
providerExecuted: true,
|
||||
@@ -630,7 +620,6 @@ Recent work
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-failed",
|
||||
itemId: "call_failed",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
@@ -639,7 +628,6 @@ Recent work
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-failed",
|
||||
itemId: "result_failed",
|
||||
name: "web_search",
|
||||
result: {
|
||||
type: "error",
|
||||
@@ -710,7 +698,6 @@ Recent work
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-old-model",
|
||||
itemId: undefined,
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
@@ -719,7 +706,6 @@ Recent work
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "hosted-old-model",
|
||||
itemId: "hosted-old-model",
|
||||
name: "web_search",
|
||||
result: { type: "text", value: '{"status":"completed"}' },
|
||||
providerExecuted: true,
|
||||
@@ -732,7 +718,6 @@ Recent work
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "local-old-model",
|
||||
itemId: undefined,
|
||||
name: "read",
|
||||
input: { path: "README.md" },
|
||||
providerExecuted: false,
|
||||
@@ -743,7 +728,6 @@ Recent work
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "local-old-model",
|
||||
itemId: "fco_local-old-model",
|
||||
name: "read",
|
||||
result: { type: "text", value: "Hello" },
|
||||
providerExecuted: false,
|
||||
|
||||
@@ -24,7 +24,9 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru
|
||||
const publish = Effect.sync(() => {
|
||||
const event = { id: Event.ID.create(), type: definition.type, data } as Event.Payload<typeof definition>
|
||||
published.push({
|
||||
type: definition.durable ? Bus.versionedType(definition.type, definition.durable.version) : definition.type,
|
||||
type: definition.durable
|
||||
? Bus.versionedType(definition.type, definition.durable.version)
|
||||
: definition.type,
|
||||
data,
|
||||
})
|
||||
return event
|
||||
@@ -64,10 +66,9 @@ const hostedResult = LLMEvent.toolResult({
|
||||
|
||||
test("local tool success serializes media base64 once through canonical content", async () => {
|
||||
const { published, publisher } = capture()
|
||||
const localCall = LLMEvent.toolCall({ ...call, itemId: "fc_call-image" })
|
||||
await Effect.runPromise(publisher.publish(localCall))
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(
|
||||
publisher.toolExecution(localCall.id, localCall.name, {
|
||||
publisher.toolExecution(call.id, call.name, {
|
||||
output: { type: "media", mime: "image/png" },
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
@@ -83,11 +84,6 @@ test("local tool success serializes media base64 once through canonical content"
|
||||
expect(success?.data).not.toHaveProperty("result")
|
||||
expect(success?.data).not.toHaveProperty("output")
|
||||
|
||||
const called = published.find((event) => event.type === "session.tool.called.1")?.data
|
||||
expect(called).toMatchObject({ state: { itemId: "fc_call-image" } })
|
||||
expect(success?.data).not.toHaveProperty("resultState")
|
||||
expect(JSON.stringify(success?.data)).not.toContain('"itemId":"fc_call-image"')
|
||||
|
||||
expect(success?.data).toMatchObject({
|
||||
content: [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
@@ -230,7 +226,9 @@ test("provider-executed tool metadata is flattened using the route key", async (
|
||||
test("binary failure emits no success event", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(call))
|
||||
await Effect.runPromise(publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }))
|
||||
await Effect.runPromise(
|
||||
publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }),
|
||||
)
|
||||
expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false)
|
||||
expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true)
|
||||
})
|
||||
|
||||
@@ -3,12 +3,10 @@ import { Agent } from "@opencode-ai/core/agent"
|
||||
import type { Permission } from "@opencode-ai/core/permission"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { executeTool, toolDefinitions } from "./lib/tool"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -28,14 +26,10 @@ const imageStore = Layer.mock(Image.Service, {
|
||||
maxBytes: 5,
|
||||
}),
|
||||
)
|
||||
return Effect.succeed({
|
||||
...content,
|
||||
content: Buffer.from(`${Buffer.from(content.content, "base64").toString()} normalized`).toString("base64"),
|
||||
mime: "image/jpeg",
|
||||
})
|
||||
return Effect.succeed({ ...content, content: "bm9ybWFsaXplZA==", mime: "image/jpeg" })
|
||||
},
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, PluginHooks.node]), [[Image.node, imageStore]])
|
||||
const registryLayer = AppNodeBuilder.build(Tool.node, [[Image.node, imageStore]])
|
||||
const it = testEffect(registryLayer)
|
||||
const identity = {
|
||||
agent: Agent.ID.make("build"),
|
||||
@@ -350,7 +344,7 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes image tool output once and drops unresizable images", () =>
|
||||
it.effect("normalizes image tool output at execution and drops unresizable images", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service,
|
||||
@@ -382,12 +376,7 @@ describe("Tool", () => {
|
||||
|
||||
const execution = yield* executeTool(service, call("snapshot"))
|
||||
expect(execution.content).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==",
|
||||
mime: "image/jpeg",
|
||||
name: "frame.png",
|
||||
},
|
||||
{ type: "file", uri: "data:image/jpeg;base64,bm9ybWFsaXplZA==", mime: "image/jpeg", name: "frame.png" },
|
||||
{ type: "text", text: "snapshot" },
|
||||
{ type: "text", text: "[1 image omitted: could not be decoded.]" },
|
||||
{ type: "text", text: "[1 image omitted: could not be resized below the image size limit.]" },
|
||||
@@ -395,34 +384,6 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes image content added by an after hook", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* transform(service, { hooked: constant("original") }, { codemode: false })
|
||||
yield* hooks.register("tool", "execute.after", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.status !== "completed") return
|
||||
event.result = {
|
||||
...event.result,
|
||||
content: [
|
||||
{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" },
|
||||
],
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
expect((yield* executeTool(service, call("hooked"))).content).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:image/jpeg;base64,aW1hZ2Ugbm9ybWFsaXplZA==",
|
||||
mime: "image/jpeg",
|
||||
name: "hook.png",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes progress metadata unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
|
||||
@@ -558,15 +558,6 @@ const messageTexts = (request: LLMRequest, role: "user" | "system") =>
|
||||
const userTexts = (request: LLMRequest) => messageTexts(request, "user")
|
||||
const systemTexts = (request: LLMRequest) => messageTexts(request, "system")
|
||||
const messageRoles = (request: LLMRequest | undefined) => request?.messages.map((message) => message.role)
|
||||
const withoutItemIDs = (messages: LLMRequest["messages"]) =>
|
||||
messages.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content.map((part) => {
|
||||
if (!("itemId" in part)) return part
|
||||
const { itemId: _itemId, ...content } = part
|
||||
return content
|
||||
}),
|
||||
}))
|
||||
|
||||
const recordedEventTypes = (id: Session.ID) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -865,8 +856,8 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* Fiber.join(renamed)
|
||||
|
||||
expect(requests).toHaveLength(5)
|
||||
expect(withoutItemIDs(requests[2]!.messages)).toContainEqual(withoutItemIDs([Message.user("First prompt")])[0])
|
||||
expect(withoutItemIDs(requests[4]!.messages)).toContainEqual(withoutItemIDs([Message.user("First prompt")])[0])
|
||||
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
|
||||
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
|
||||
expect((yield* session.get(sessionID)).title).toBe("Generated title")
|
||||
}),
|
||||
)
|
||||
@@ -891,7 +882,7 @@ describe("SessionRunnerLLM", () => {
|
||||
// A hook-removed call fails independently and continues while step allowance remains.
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["Hooked system"])
|
||||
expect(withoutItemIDs(requests[0]!.messages)).toEqual(withoutItemIDs([Message.user("Hooked message")]))
|
||||
expect(requests[0]?.messages).toEqual([Message.user("Hooked message")])
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("echo")
|
||||
expect(requests[0]?.tools.map((tool) => tool.name)).not.toContain("unregistered")
|
||||
expect(executions).toEqual([])
|
||||
@@ -1316,14 +1307,12 @@ describe("SessionRunnerLLM", () => {
|
||||
systemBaseline = "Changed context"
|
||||
yield* runPrompt(session, "Second")
|
||||
|
||||
const firstSnapshot = PromptCacheDiagnostics.snapshot(requests[0]!)
|
||||
const secondSnapshot = PromptCacheDiagnostics.snapshot(requests[1]!)
|
||||
expect(PromptCacheDiagnostics.compare(firstSnapshot, secondSnapshot)).toEqual({
|
||||
status: "append-only",
|
||||
previousMessages: 1,
|
||||
currentMessages: 3,
|
||||
})
|
||||
expect(secondSnapshot.messages[0]).toEqual(firstSnapshot.messages[0])
|
||||
expect(
|
||||
PromptCacheDiagnostics.compare(
|
||||
PromptCacheDiagnostics.snapshot(requests[0]),
|
||||
PromptCacheDiagnostics.snapshot(requests[1]),
|
||||
),
|
||||
).toEqual({ status: "append-only", previousMessages: 1, currentMessages: 3 })
|
||||
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, "Initial context"],
|
||||
[defaultSystem, "Initial context"],
|
||||
@@ -2543,24 +2532,9 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Encrypted thought",
|
||||
itemId: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
|
||||
yield* admit(session, "Continue again")
|
||||
yield* TestLLM.push([])
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(requests[2]?.messages[1]?.content.map((part) => ("itemId" in part ? part.itemId : undefined))).toEqual(
|
||||
requests[1]?.messages[1]?.content.map((part) => ("itemId" in part ? part.itemId : undefined)),
|
||||
)
|
||||
expect(
|
||||
PromptCacheDiagnostics.compare(
|
||||
PromptCacheDiagnostics.snapshot(requests[1]!),
|
||||
PromptCacheDiagnostics.snapshot(requests[2]!),
|
||||
),
|
||||
).toEqual({ status: "append-only", previousMessages: 3, currentMessages: 4 })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2639,7 +2613,6 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "hosted-search",
|
||||
itemId: "hosted-search",
|
||||
name: "web_search",
|
||||
input: { query: "Effect" },
|
||||
providerExecuted: true,
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"modal": "0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Effect, Sink, Stream } from "effect"
|
||||
import { systemError } from "effect/PlatformError"
|
||||
import type { Command, KillOptions } from "effect/unstable/process/ChildProcess"
|
||||
import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver } from "@opencode-ai/core/environment"
|
||||
import type { ModalClientParams, Sandbox, SandboxCreateParams } from "modal"
|
||||
|
||||
const INNER_WRAPPER = `
|
||||
pidfile=$1
|
||||
shift
|
||||
printf "%s" "$$" > "$pidfile"
|
||||
trap 'rm -f -- "$pidfile"' EXIT
|
||||
"$@"
|
||||
`
|
||||
|
||||
const KILL = `
|
||||
i=0
|
||||
while [ ! -s "$1" ] && [ "$i" -lt 250 ]; do sleep 0.02; i=$((i + 1)); done
|
||||
if [ -s "$1" ]; then
|
||||
pid=$(cat "$1")
|
||||
/bin/kill "-$2" "-$pid" 2>/dev/null || true
|
||||
else
|
||||
exit 47
|
||||
fi
|
||||
`
|
||||
|
||||
export interface ModalImageSpec {
|
||||
readonly registry: string
|
||||
readonly dockerfileCommands: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export interface ModalSandboxOptions {
|
||||
readonly app: string
|
||||
readonly client?: ModalClientParams
|
||||
readonly image?: ModalImageSpec
|
||||
readonly sandbox?: SandboxCreateParams
|
||||
}
|
||||
|
||||
/**
|
||||
* Ubuntu supplies the GNU coreutils and findutils required by the derived Files
|
||||
* scripts. Busybox images do not satisfy the Environment contract.
|
||||
*/
|
||||
export const ubuntuImage: ModalImageSpec = {
|
||||
registry: "ubuntu:24.04",
|
||||
dockerfileCommands: [
|
||||
"RUN apt-get update && apt-get install -y --no-install-recommends git bash ripgrep ca-certificates coreutils findutils util-linux",
|
||||
],
|
||||
}
|
||||
|
||||
/** Creates a Modal sandbox lazily, keeping the SDK off the server startup path when Modal is unused. */
|
||||
export const createModalSandbox = async (options: ModalSandboxOptions) => {
|
||||
const { ModalClient } = await import("modal")
|
||||
const client = new ModalClient(options.client)
|
||||
const app = await client.apps.fromName(options.app, { createIfMissing: true })
|
||||
const imageSpec = options.image ?? ubuntuImage
|
||||
const image = client.images.fromRegistry(imageSpec.registry).dockerfileCommands([...imageSpec.dockerfileCommands])
|
||||
const sandbox = await client.sandboxes.create(app, image, options.sandbox)
|
||||
return {
|
||||
driver: makeModalDriver(sandbox),
|
||||
sandbox,
|
||||
terminate: () => sandbox.terminate(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts Modal exec to the Environment driver. Files intentionally has no native
|
||||
* overrides: Modal exec and filesystem tools share the same roughly 175ms floor,
|
||||
* so the derived exec defaults are the simplest implementation with no measured loss.
|
||||
*
|
||||
* Modal cannot signal a ContainerProcess. Each command therefore starts a new
|
||||
* process group and records its leader in a unique pid file; kill runs a second
|
||||
* sandbox command that signals that group. Pid files are removed best-effort.
|
||||
*/
|
||||
export const makeModalDriver = (sandbox: Sandbox): Driver => {
|
||||
const spawn = Effect.fnUntraced(function* (command: Command) {
|
||||
if (command._tag === "PipedCommand") {
|
||||
return yield* Effect.fail(spawnError("spawn", "piped commands unsupported"))
|
||||
}
|
||||
if (command.options.additionalFds) {
|
||||
return yield* Effect.fail(spawnError("spawn", "additional file descriptors unsupported"))
|
||||
}
|
||||
|
||||
const pidFile = `/tmp/opencode-process-${crypto.randomUUID()}.pid`
|
||||
const env = compact(command.options.env)
|
||||
const isolatedEnv =
|
||||
command.options.extendEnv === false || (!command.options.extendEnv && command.options.env !== undefined)
|
||||
const argv = isolatedEnv ? ["env", "-i", ...Object.entries(env ?? {}).map(([key, value]) => `${key}=${value}`)] : []
|
||||
const process = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
sandbox.exec(
|
||||
["setsid", "--wait", "sh", "-c", INNER_WRAPPER, "sh", pidFile, ...argv, command.command, ...command.args],
|
||||
{
|
||||
mode: "binary",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
workdir: command.options.cwd,
|
||||
env: isolatedEnv ? undefined : env,
|
||||
},
|
||||
),
|
||||
catch: (cause) => spawnError("spawn", undefined, cause),
|
||||
})
|
||||
|
||||
const onError = (cause: unknown) => spawnError("process", undefined, cause)
|
||||
let exited = false
|
||||
const writer = process.stdin.getWriter()
|
||||
let closingStdin: Promise<void> | undefined
|
||||
const waited = process.wait().then((code) => {
|
||||
exited = true
|
||||
if (!closingStdin) writer.releaseLock()
|
||||
return code
|
||||
})
|
||||
const exitCode = Effect.tryPromise({ try: () => waited, catch: onError }).pipe(Effect.map(ExitCode))
|
||||
const kill = (options?: KillOptions) => {
|
||||
if (exited) return Effect.void
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const killer = await sandbox.exec(["sh", "-c", KILL, "sh", pidFile, options?.killSignal ?? "SIGTERM"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const code = await killer.wait()
|
||||
if (code !== 0) throw new Error(`modal kill exited ${code}`)
|
||||
},
|
||||
catch: onError,
|
||||
}).pipe(Effect.andThen(exitCode), Effect.asVoid)
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() => kill(command.options).pipe(Effect.ignore))
|
||||
|
||||
const closeStdin = Effect.tryPromise({
|
||||
try: () => (closingStdin ??= writer.close().finally(() => writer.releaseLock())),
|
||||
catch: onError,
|
||||
})
|
||||
const writeStdin = Sink.forEach((chunk: Uint8Array) =>
|
||||
Effect.tryPromise({ try: () => writer.write(chunk), catch: onError }),
|
||||
)
|
||||
const inputConfig = command.options.stdin
|
||||
const inputOptions =
|
||||
inputConfig !== undefined && typeof inputConfig === "object" && !Stream.isStream(inputConfig)
|
||||
? inputConfig
|
||||
: undefined
|
||||
const input = inputOptions?.stream ?? inputConfig
|
||||
const stdin = inputOptions?.endOnDone === false ? writeStdin : writeStdin.pipe(Sink.ensuring(closeStdin))
|
||||
if (input === "ignore") {
|
||||
yield* closeStdin
|
||||
}
|
||||
if (Stream.isStream(input)) {
|
||||
yield* Effect.forkScoped(Stream.run(input, stdin))
|
||||
}
|
||||
|
||||
const stdout = Stream.fromReadableStream({ evaluate: () => process.stdout, onError })
|
||||
const stderr = Stream.fromReadableStream({ evaluate: () => process.stderr, onError })
|
||||
return makeHandle({
|
||||
pid: ProcessId(crypto.getRandomValues(new Uint32Array(1))[0]),
|
||||
exitCode,
|
||||
isRunning: Effect.sync(() => !exited),
|
||||
kill,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
all: Stream.merge(stdout, stderr),
|
||||
getInputFd: () => Sink.fail(spawnError("getInputFd", "unsupported")),
|
||||
getOutputFd: () => Stream.fail(spawnError("getOutputFd", "unsupported")),
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
})
|
||||
|
||||
return { spawner: make(spawn) }
|
||||
}
|
||||
|
||||
const compact = (env: Record<string, string | undefined> | undefined) => {
|
||||
if (!env) return undefined
|
||||
return Object.fromEntries(Object.entries(env).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])))
|
||||
}
|
||||
|
||||
const spawnError = (method: string, description?: string, cause?: unknown) =>
|
||||
systemError({ _tag: "Unknown", module: "ModalDriver", method, description, cause })
|
||||
|
||||
export * as ModalDriver from "./modal"
|
||||
@@ -0,0 +1,67 @@
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Failed, makeFiles } from "@opencode-ai/core/environment"
|
||||
import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance"
|
||||
import { createModalSandbox } from "../src/workspace/modal"
|
||||
|
||||
const enabled =
|
||||
!!process.env.OPENCODE_TEST_MODAL &&
|
||||
((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) ||
|
||||
fs.existsSync(path.join(os.homedir(), ".modal.toml")))
|
||||
const modalTest = enabled ? test : test.skip
|
||||
const root = `/tmp/opencode-environment-${crypto.randomUUID()}`
|
||||
const sandbox = enabled
|
||||
? createModalSandbox({
|
||||
app: "opencode-environment-tests",
|
||||
sandbox: { timeoutMs: 10 * 60 * 1000 },
|
||||
})
|
||||
: undefined
|
||||
|
||||
modalTest(
|
||||
"kills a running modal process",
|
||||
async () => {
|
||||
const value = await sandbox!
|
||||
const started = performance.now()
|
||||
const exitCode = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* value.driver.spawner.spawn(ChildProcess.make("sleep", ["300"]))
|
||||
yield* handle.kill()
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
).pipe(Effect.timeout("10 seconds")),
|
||||
)
|
||||
expect(exitCode).not.toBe(0)
|
||||
expect(performance.now() - started).toBeLessThan(10_000)
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
environmentConformance(
|
||||
"modal environment",
|
||||
() =>
|
||||
Effect.promise(async () => {
|
||||
const value = await sandbox!
|
||||
return {
|
||||
files: makeFiles(value.driver),
|
||||
root,
|
||||
symlink: (target, link) =>
|
||||
Effect.scoped(value.driver.spawner.exitCode(ChildProcess.make("ln", ["-s", "--", target, link]))).pipe(
|
||||
Effect.flatMap((code) =>
|
||||
code === 0 ? Effect.void : Effect.fail(new Failed({ path: link, cause: new Error(`ln exited ${code}`) })),
|
||||
),
|
||||
Effect.mapError((cause) => (cause instanceof Failed ? cause : new Failed({ path: link, cause }))),
|
||||
),
|
||||
}
|
||||
}),
|
||||
!enabled,
|
||||
)
|
||||
|
||||
afterAll(async () => {
|
||||
if (!sandbox) return
|
||||
await (await sandbox).terminate()
|
||||
})
|
||||
Reference in New Issue
Block a user