Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d929f78c3 | ||
|
|
d4f83ba2b7 | ||
|
|
93cc5e8dff | ||
|
|
3fcdf94873 | ||
|
|
d16bfe850a | ||
|
|
728eae9c25 | ||
|
|
eb84f461b8 | ||
|
|
0b3a1c2fdf | ||
|
|
1ccd14b0e1 | ||
|
|
61e7cdfbff | ||
|
|
7cfcb644ab | ||
|
|
7a58560161 | ||
|
|
1edba25dfb | ||
|
|
6d3c4c916b |
@@ -199,15 +199,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/google": "3.0.75",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
@@ -409,15 +409,15 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/google": "3.0.75",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
@@ -810,7 +810,7 @@
|
||||
|
||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="],
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||
|
||||
@@ -832,9 +832,9 @@
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.131", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/google": "3.0.75", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Oj1X8p0rVgvEoR5OOSxWi6XgzJ3QDlE/n30MZVtpKkCiToYYDyvlvVDGXz3IqhMyUev2JhlcuUk1brScKT01kA=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||
|
||||
@@ -5090,9 +5090,13 @@
|
||||
|
||||
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
@@ -5108,11 +5112,17 @@
|
||||
|
||||
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -6006,6 +6016,12 @@
|
||||
|
||||
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
@@ -6016,6 +6032,14 @@
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-qq8cKNcW10NRbPx2T/Gpzby4nx2qHzj0LWDwqyPl7rY=",
|
||||
"aarch64-linux": "sha256-UguK4nObAL/ovGZQousveoXCmkcxEgWy7XSSzxVJNEY=",
|
||||
"aarch64-darwin": "sha256-NibSqZiF0XMbFhOtl2CQvFe4N/BXB5UAdt2iKv7dIcI=",
|
||||
"x86_64-darwin": "sha256-ZNeZSpYitdUjXx/sULAIoLXrM6+Vz+GXQxKpdfgXh60="
|
||||
"x86_64-linux": "sha256-aHOQ9TMdmfbSIuKee1uflHiuAQ3+69NH7RUMv6LztVY=",
|
||||
"aarch64-linux": "sha256-DBBT/nLkLOtHlKL3ea/Q7ysTG5asnWUx0zQ90+D6cuI=",
|
||||
"aarch64-darwin": "sha256-VIiz1RRTu/qdWd63tXv5kxVTFYjqZbnbaKaR7V++7lI=",
|
||||
"x86_64-darwin": "sha256-TUtJaedvRpZcM7Fs5/P6fkI/aT+rIo3Wsp9kZiN45Rg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/google": "3.0.75",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
|
||||
+6
-1
@@ -15,6 +15,7 @@ type ServiceUse<Identifier, Shape> = {
|
||||
}
|
||||
|
||||
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
|
||||
const cache = new Map<string, (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>>()
|
||||
// This is the only dynamic boundary: TypeScript knows the accessor shape,
|
||||
// but Proxy property names are runtime values.
|
||||
const access = new Proxy(
|
||||
@@ -22,7 +23,9 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
|
||||
{
|
||||
get: (_, key) => {
|
||||
if (typeof key !== "string") return undefined
|
||||
return (...args: unknown[]) =>
|
||||
const cached = cache.get(key)
|
||||
if (cached) return cached
|
||||
const accessor = (...args: unknown[]) =>
|
||||
tag.use((service) => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
|
||||
const method = service[key as keyof Shape]
|
||||
@@ -30,6 +33,8 @@ export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, S
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
|
||||
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
|
||||
})
|
||||
cache.set(key, accessor)
|
||||
return accessor
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -3,9 +3,10 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
import { lookup } from "mime-types"
|
||||
import { Effect, FileSystem, Layer, Schema, Context } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
import { serviceUse } from "./effect/service-use"
|
||||
|
||||
export namespace AppFileSystem {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
@@ -39,6 +40,8 @@ export namespace AppFileSystem {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ProviderOptions } from "./utils/provider-options"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
@@ -137,7 +136,6 @@ const AnthropicTool = Schema.Struct({
|
||||
description: Schema.String,
|
||||
input_schema: JsonObject,
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
eager_input_streaming: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
||||
|
||||
@@ -146,10 +144,10 @@ const AnthropicToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||
])
|
||||
|
||||
// Anthropic accepts several `thinking` shapes (enabled with `budget_tokens`,
|
||||
// adaptive with optional `display`, and disabled). The body schema permits the
|
||||
// full union so explicit lowering can pick the correct fields per model.
|
||||
const AnthropicThinkingBody = Schema.Record(Schema.String, Schema.Unknown)
|
||||
const AnthropicThinking = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
})
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
model: Schema.String,
|
||||
@@ -163,12 +161,9 @@ const AnthropicBodyFields = {
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
top_k: Schema.optional(Schema.Number),
|
||||
stop_sequences: optionalArray(Schema.String),
|
||||
thinking: Schema.optional(AnthropicThinkingBody),
|
||||
thinking: Schema.optional(AnthropicThinking),
|
||||
}
|
||||
// Unknown provider options pass through verbatim with top-level keys snake-cased.
|
||||
const AnthropicMessagesBody = Schema.StructWithRest(Schema.Struct(AnthropicBodyFields), [
|
||||
Schema.Record(Schema.String, Schema.Any),
|
||||
])
|
||||
const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicUsage = Schema.Struct({
|
||||
@@ -259,16 +254,11 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
return typeof anthropic.signature === "string" ? anthropic.signature : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (
|
||||
breakpoints: Cache.Breakpoints,
|
||||
tool: ToolDefinition,
|
||||
eagerInputStreaming: boolean | undefined,
|
||||
): AnthropicTool => ({
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.inputSchema,
|
||||
cache_control: cacheControl(breakpoints, tool.cache),
|
||||
eager_input_streaming: eagerInputStreaming ? true : undefined,
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -423,46 +413,24 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
// Typed AI SDK Anthropic options. Mirrors the subset of
|
||||
// `anthropicLanguageModelOptions` that opencode's provider transform actually
|
||||
// emits today (see `packages/opencode/src/provider/transform.ts`). Unknown
|
||||
// keys flow through the index signature and pass through to the wire body
|
||||
// with their top-level key snake-cased.
|
||||
type AnthropicEffort = "low" | "medium" | "high" | "xhigh" | "max"
|
||||
type AnthropicThinking =
|
||||
| { readonly type: "enabled"; readonly budgetTokens?: number; readonly budget_tokens?: number }
|
||||
| { readonly type: "adaptive"; readonly display?: "omitted" | "summarized" }
|
||||
| { readonly type: "disabled" }
|
||||
const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic
|
||||
|
||||
interface AnthropicOptions {
|
||||
readonly thinking?: AnthropicThinking
|
||||
readonly effort?: AnthropicEffort
|
||||
readonly toolStreaming?: boolean
|
||||
readonly [extra: string]: unknown
|
||||
}
|
||||
|
||||
const ANTHROPIC_KNOWN_KEYS: ReadonlySet<string> = new Set(["thinking", "effort", "toolStreaming"])
|
||||
|
||||
const lowerThinking = (thinking: AnthropicOptions["thinking"]) => {
|
||||
if (thinking === undefined) return undefined
|
||||
if (thinking.type === "disabled") return undefined
|
||||
if (thinking.type === "adaptive") {
|
||||
return { type: "adaptive" as const, ...(thinking.display ? { display: thinking.display } : {}) }
|
||||
}
|
||||
const budget = thinking.budgetTokens ?? thinking.budget_tokens
|
||||
if (budget === undefined) return undefined
|
||||
const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) {
|
||||
const thinking = anthropicOptions(request)?.thinking
|
||||
if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined
|
||||
const budget =
|
||||
typeof thinking.budgetTokens === "number"
|
||||
? thinking.budgetTokens
|
||||
: typeof thinking.budget_tokens === "number"
|
||||
? thinking.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget }
|
||||
}
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
const options = ProviderOptions.merge(request, ["anthropic"]) as AnthropicOptions
|
||||
// AI SDK's `toolStreaming` controls per-tool `eager_input_streaming`. opencode
|
||||
// sets `toolStreaming: false` for non-Claude models routed through
|
||||
// `@ai-sdk/anthropic`; otherwise the field is left unset so the provider
|
||||
// applies its own default.
|
||||
const eagerInputStreaming = options.toolStreaming === true
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
@@ -470,7 +438,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
const tools =
|
||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||
? undefined
|
||||
: request.tools.map((tool) => lowerTool(breakpoints, tool, eagerInputStreaming))
|
||||
: request.tools.map((tool) => lowerTool(breakpoints, tool))
|
||||
const system =
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
@@ -486,7 +454,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
return {
|
||||
...ProviderOptions.passthrough(options, ANTHROPIC_KNOWN_KEYS),
|
||||
model: request.model.id,
|
||||
system,
|
||||
messages,
|
||||
@@ -498,8 +465,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: lowerThinking(options.thinking),
|
||||
...(options.effort !== undefined ? { effort: options.effort } : {}),
|
||||
thinking: yield* lowerThinking(request),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../schema"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { ProviderOptions } from "./utils/provider-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
@@ -51,10 +50,6 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
// `reasoning_content` is a plain string per DeepSeek/OpenAI-compatible spec.
|
||||
// `reasoning_details` is an OpenRouter-style array of typed reasoning objects
|
||||
// (summary / encrypted / text). We accept the structured payload as-is so it
|
||||
// round-trips verbatim to the provider on continuation requests.
|
||||
const OpenAIChatMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.Literal("user"), content: Schema.String }),
|
||||
@@ -63,7 +58,6 @@ const OpenAIChatMessage = Schema.Union([
|
||||
content: Schema.NullOr(Schema.String),
|
||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||
reasoning_content: Schema.optional(Schema.String),
|
||||
reasoning_details: Schema.optional(Schema.Array(Schema.Record(Schema.String, Schema.Unknown))),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
@@ -85,7 +79,7 @@ export const bodyFields = {
|
||||
stream: Schema.Literal(true),
|
||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
reasoning_effort: Schema.optional(Schema.String),
|
||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
@@ -94,7 +88,7 @@ export const bodyFields = {
|
||||
seed: Schema.optional(Schema.Number),
|
||||
stop: optionalArray(Schema.String),
|
||||
}
|
||||
const OpenAIChatBody = Schema.StructWithRest(Schema.Struct(bodyFields), [Schema.Record(Schema.String, Schema.Any)])
|
||||
const OpenAIChatBody = Schema.Struct(bodyFields)
|
||||
export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
|
||||
|
||||
// =============================================================================
|
||||
@@ -131,16 +125,9 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||
|
||||
// Streaming reasoning fields. `reasoning_content` (DeepSeek) and `reasoning`
|
||||
// (AI SDK fallback) are strings; `reasoning_details` (OpenRouter) is an array
|
||||
// of typed reasoning detail objects. We surface their plaintext via reasoning
|
||||
// deltas and preserve the structured array for downstream round-trip.
|
||||
const OpenAIChatReasoningDetail = Schema.Record(Schema.String, Schema.Unknown)
|
||||
const OpenAIChatDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
reasoning: optionalNull(Schema.String),
|
||||
reasoning_details: optionalNull(Schema.Array(OpenAIChatReasoningDetail)),
|
||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||
})
|
||||
|
||||
@@ -201,16 +188,6 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
|
||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||
|
||||
// `reasoning_details` rounds-trips the OpenRouter structured array. Accept the
|
||||
// array shape as canonical; tolerate a string for legacy callers that already
|
||||
// flattened it.
|
||||
const openAICompatibleReasoningDetails = (native: unknown) => {
|
||||
if (!isRecord(native)) return undefined
|
||||
const value = native.reasoning_details
|
||||
if (Array.isArray(value)) return value as ReadonlyArray<Record<string, unknown>>
|
||||
return undefined
|
||||
}
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
const content: TextPart[] = []
|
||||
for (const part of message.content) {
|
||||
@@ -243,7 +220,6 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_content: openAICompatibleReasoningContent(message.native?.openaiCompatible),
|
||||
reasoning_details: openAICompatibleReasoningDetails(message.native?.openaiCompatible),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -270,14 +246,13 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
})
|
||||
|
||||
const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) {
|
||||
const options = OpenAIOptions.options(request)
|
||||
const effort = options.reasoningEffort
|
||||
if (effort !== undefined && !OpenAIOptions.isReasoningEffort(effort))
|
||||
return yield* invalid(`OpenAI Chat does not support reasoning effort ${effort}`)
|
||||
const store = OpenAIOptions.store(request)
|
||||
const reasoningEffort = OpenAIOptions.reasoningEffort(request)
|
||||
if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort))
|
||||
return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`)
|
||||
return {
|
||||
...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(effort !== undefined ? { reasoning_effort: effort } : {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -350,15 +325,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
|
||||
let lifecycle = state.lifecycle
|
||||
|
||||
// OpenRouter-style `reasoning_details` ships an array of typed reasoning
|
||||
// objects (summary / text / encrypted). Concatenate the plaintext fields
|
||||
// into the reasoning delta stream; the structured array is preserved on
|
||||
// the assistant message for round-trip via `providerMetadata`.
|
||||
const detailText = (delta?.reasoning_details ?? [])
|
||||
.map((detail) => (typeof detail.text === "string" ? detail.text : typeof detail.summary === "string" ? detail.summary : ""))
|
||||
.join("")
|
||||
const reasoning = delta?.reasoning_content ?? delta?.reasoning ?? (detailText.length > 0 ? detailText : undefined)
|
||||
if (reasoning) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning)
|
||||
if (delta?.reasoning_content)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
||||
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
|
||||
|
||||
@@ -1,84 +1,22 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route, type RouteRoutedModelInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import type { LLMRequest } from "../schema"
|
||||
import { ProviderOptions } from "./utils/provider-options"
|
||||
import * as OpenAIChat from "./openai-chat"
|
||||
|
||||
const ADAPTER = "openai-compatible-chat"
|
||||
|
||||
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
|
||||
|
||||
const OpenAICompatibleChatBody = Schema.StructWithRest(
|
||||
Schema.Struct({ ...OpenAIChat.bodyFields, reasoning_effort: Schema.optional(Schema.String) }),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
export type OpenAICompatibleChatBody = Schema.Schema.Type<typeof OpenAICompatibleChatBody>
|
||||
|
||||
// Typed AI SDK `@ai-sdk/openai-compatible` options. Known keys are lowered
|
||||
// explicitly; everything else passes through to the wire body with its
|
||||
// top-level key snake-cased.
|
||||
interface CompatibleOptions {
|
||||
readonly user?: string
|
||||
readonly reasoningEffort?: string
|
||||
readonly textVerbosity?: string
|
||||
readonly strictJsonSchema?: boolean
|
||||
readonly [extra: string]: unknown
|
||||
}
|
||||
const COMPATIBLE_KNOWN_KEYS: ReadonlySet<string> = new Set([
|
||||
"user",
|
||||
"reasoningEffort",
|
||||
"textVerbosity",
|
||||
"strictJsonSchema",
|
||||
])
|
||||
|
||||
// Match AI SDK `@ai-sdk/openai-compatible` option resolution: the deprecated
|
||||
// `openai-compatible` alias, the canonical `openaiCompatible` key, the raw
|
||||
// provider name (dot-split so e.g. `opencode.internal` matches `opencode`),
|
||||
// and its camelCase variant. Later sources override earlier ones.
|
||||
const bodyOptions = (request: LLMRequest) => {
|
||||
const provider = String(request.model.provider).split(".")[0]
|
||||
const camel = provider.replace(/[_-]([a-z])/g, (_, value: string) => value.toUpperCase())
|
||||
const options = ProviderOptions.merge(request, [
|
||||
"openai-compatible",
|
||||
"openaiCompatible",
|
||||
provider,
|
||||
camel,
|
||||
]) as CompatibleOptions
|
||||
return {
|
||||
...ProviderOptions.passthrough(options, COMPATIBLE_KNOWN_KEYS),
|
||||
...(options.user !== undefined ? { user: options.user } : {}),
|
||||
...(options.reasoningEffort !== undefined ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
...(options.textVerbosity !== undefined ? { verbosity: options.textVerbosity } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
schema: OpenAICompatibleChatBody,
|
||||
// Drop providerOptions before delegating so OpenAI Chat's OpenAI-only
|
||||
// option validation does not reject compatible-route requests whose
|
||||
// provider id happens to be `openai` or use extended reasoning efforts.
|
||||
from: (request) =>
|
||||
OpenAIChat.protocol.body
|
||||
.from({ ...request, providerOptions: undefined })
|
||||
.pipe(Effect.map((body) => ({ ...body, ...bodyOptions(request) }))),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
/**
|
||||
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible
|
||||
* `/chat/completions` endpoint. Reuses OpenAI Chat streaming behavior while
|
||||
* allowing compatible providers to pass through additional request-body
|
||||
* options such as `enable_thinking` and extended reasoning efforts.
|
||||
* `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and
|
||||
* overrides only the route id so providers can be resolved per-family without
|
||||
* colliding with native OpenAI. Provider helpers configure the route endpoint
|
||||
* before model selection.
|
||||
*/
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
protocol,
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions"),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { ProviderOptions } from "./utils/provider-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
@@ -58,6 +57,11 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIResponsesItemReference = Schema.Struct({
|
||||
type: Schema.tag("item_reference"),
|
||||
id: Schema.String,
|
||||
})
|
||||
|
||||
// `function_call_output.output` accepts either a plain string or an ordered
|
||||
// array of content items so tools can return images in addition to text.
|
||||
// https://platform.openai.com/docs/api-reference/responses/object
|
||||
@@ -73,6 +77,7 @@ const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
||||
OpenAIResponsesReasoningItem,
|
||||
OpenAIResponsesItemReference,
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call"),
|
||||
call_id: Schema.String,
|
||||
@@ -87,6 +92,15 @@ const OpenAIResponsesInputItem = Schema.Union([
|
||||
])
|
||||
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
|
||||
|
||||
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
type OpenAIResponsesReasoningInput = {
|
||||
type: "reasoning"
|
||||
id: string
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
|
||||
const OpenAIResponsesTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
name: Schema.String,
|
||||
@@ -112,23 +126,12 @@ const OpenAIResponsesCoreFields = {
|
||||
tools: optionalArray(OpenAIResponsesTool),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
conversation: Schema.optional(Schema.String),
|
||||
max_tool_calls: Schema.optional(Schema.Number),
|
||||
metadata: Schema.optional(JsonObject),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
previous_response_id: Schema.optional(Schema.String),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
prompt_cache_retention: Schema.optional(Schema.String),
|
||||
safety_identifier: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(Schema.String),
|
||||
top_logprobs: Schema.optional(Schema.Number),
|
||||
truncation: Schema.optional(Schema.String),
|
||||
user: Schema.optional(Schema.String),
|
||||
include: optionalArray(Schema.String),
|
||||
include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
|
||||
reasoning: Schema.optional(
|
||||
Schema.Struct({
|
||||
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
summary: Schema.optional(Schema.String),
|
||||
summary: Schema.optional(Schema.Literal("auto")),
|
||||
}),
|
||||
),
|
||||
text: Schema.optional(
|
||||
@@ -141,15 +144,10 @@ const OpenAIResponsesCoreFields = {
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
}
|
||||
|
||||
// Unknown provider options are passed through verbatim with their top-level
|
||||
// key snake-cased; the rest record validates them against any JSON value.
|
||||
const OpenAIResponsesBody = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...OpenAIResponsesCoreFields,
|
||||
stream: Schema.Literal(true),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
const OpenAIResponsesBody = Schema.Struct({
|
||||
...OpenAIResponsesCoreFields,
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
|
||||
@@ -210,6 +208,7 @@ const OpenAIResponsesEvent = Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(OpenAIResponsesStreamItem),
|
||||
response: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
@@ -233,6 +232,18 @@ interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
// and matches the wire field.
|
||||
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
|
||||
}
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
@@ -262,22 +273,21 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
})
|
||||
|
||||
const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => {
|
||||
const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
|
||||
const openai = part.providerMetadata?.openai
|
||||
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
|
||||
// With store:false, OpenAI only accepts previous reasoning items when the
|
||||
// encrypted state is present. Bare rs_* ids point to non-persisted items.
|
||||
if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined
|
||||
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
|
||||
return undefined
|
||||
const encryptedContent =
|
||||
typeof openai.reasoningEncryptedContent === "string"
|
||||
? openai.reasoningEncryptedContent
|
||||
: openai.reasoningEncryptedContent === null
|
||||
? null
|
||||
: undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
id: openai.itemId,
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content:
|
||||
typeof openai.reasoningEncryptedContent === "string"
|
||||
? openai.reasoningEncryptedContent
|
||||
: openai.reasoningEncryptedContent === null
|
||||
? null
|
||||
: undefined,
|
||||
encrypted_content: encryptedContent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,15 +320,14 @@ const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")
|
||||
// Text/json/error results are encoded as a plain string for backward
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
const items: ReadonlyArray<ToolResultContentPart> = part.result.value
|
||||
return yield* Effect.forEach(items, lowerToolResultContentItem)
|
||||
return yield* Effect.forEach(part.result.value, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
|
||||
const system: OpenAIResponsesInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: OpenAIResponsesInputItem[] = [...system]
|
||||
const store = OpenAIOptions.options(request).store
|
||||
const store = OpenAIOptions.store(request)
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "user") {
|
||||
@@ -328,6 +337,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
||||
@@ -340,8 +351,22 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, store)
|
||||
if (reasoning) input.push(reasoning)
|
||||
const reasoning = lowerReasoning(part)
|
||||
if (!reasoning) continue
|
||||
if (store !== false && reasoning.id) {
|
||||
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)
|
||||
if (typeof reasoning.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
reasoningItems[reasoning.id] = reasoning
|
||||
input.push(reasoning)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -370,50 +395,35 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
// With store:false, OpenAI only accepts previous reasoning items when the
|
||||
// complete item has encrypted state. Summary blocks for one item may carry
|
||||
// that state only on the last block, so filter after they have been joined.
|
||||
return store === false
|
||||
? input.filter(
|
||||
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
|
||||
)
|
||||
: input
|
||||
})
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.options(request)
|
||||
// OpenAI Responses does not accept the `max` reasoning effort variant.
|
||||
const effort = OpenAIOptions.isReasoningEffort(options.reasoningEffort) ? options.reasoningEffort : undefined
|
||||
const summary = options.reasoningSummary
|
||||
const verbosity = options.textVerbosity
|
||||
// `logprobs` is enabled only by `true` or a numeric top-N. `false` and
|
||||
// `undefined` leave the request without the logprobs include + top_logprobs.
|
||||
const logprobsEnabled = options.logprobs === true || typeof options.logprobs === "number"
|
||||
const include = (() => {
|
||||
const base = options.include ? [...options.include] : []
|
||||
if (options.includeEncryptedReasoning && !base.includes("reasoning.encrypted_content")) {
|
||||
base.push("reasoning.encrypted_content")
|
||||
}
|
||||
if (logprobsEnabled && !base.includes("message.output_text.logprobs")) {
|
||||
base.push("message.output_text.logprobs")
|
||||
}
|
||||
return base.length > 0 ? base : undefined
|
||||
})()
|
||||
const topLogprobs = typeof options.logprobs === "number" ? options.logprobs : options.logprobs === true ? 20 : undefined
|
||||
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
|
||||
const store = OpenAIOptions.store(request)
|
||||
const promptCacheKey = OpenAIOptions.promptCacheKey(request)
|
||||
const effort = OpenAIOptions.reasoningEffort(request)
|
||||
if (effort && !OpenAIOptions.isReasoningEffort(effort))
|
||||
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
||||
const summary = OpenAIOptions.reasoningSummary(request)
|
||||
const include = OpenAIOptions.include(request)
|
||||
const verbosity = OpenAIOptions.textVerbosity(request)
|
||||
const instructions = OpenAIOptions.instructions(request)
|
||||
return {
|
||||
...ProviderOptions.passthrough(options, OpenAIOptions.KNOWN_KEYS),
|
||||
...(options.instructions !== undefined ? { instructions: options.instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.conversation !== undefined ? { conversation: options.conversation } : {}),
|
||||
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
|
||||
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
|
||||
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
|
||||
...(options.previousResponseId !== undefined ? { previous_response_id: options.previousResponseId } : {}),
|
||||
...(options.promptCacheKey !== undefined ? { prompt_cache_key: options.promptCacheKey } : {}),
|
||||
...(options.promptCacheRetention !== undefined ? { prompt_cache_retention: options.promptCacheRetention } : {}),
|
||||
...(options.safetyIdentifier !== undefined ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||
...(options.serviceTier !== undefined ? { service_tier: options.serviceTier } : {}),
|
||||
...(topLogprobs !== undefined ? { top_logprobs: topLogprobs } : {}),
|
||||
...(options.truncation !== undefined ? { truncation: options.truncation } : {}),
|
||||
...(options.user !== undefined ? { user: options.user } : {}),
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
||||
...(include ? { include } : {}),
|
||||
...(effort !== undefined || summary !== undefined ? { reasoning: { effort, summary } } : {}),
|
||||
...(verbosity !== undefined ? { text: { verbosity } } : {}),
|
||||
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
||||
...(verbosity ? { text: { verbosity } } : {}),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const generation = request.generation
|
||||
@@ -426,7 +436,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
...lowerOptions(request),
|
||||
...(yield* lowerOptions(request)),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -557,24 +567,51 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
||||
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const itemID = event.item_id ?? "reasoning-0"
|
||||
const id =
|
||||
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta),
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
// The summary done event does not carry encrypted continuation state. Finish the
|
||||
// common reasoning block when the full reasoning item arrives in output_item.done.
|
||||
const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
|
||||
|
||||
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
||||
openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
|
||||
// OpenAI Responses streams reasoning items in a stable order:
|
||||
// `output_item.added` (reasoning) →
|
||||
// `reasoning_summary_part.added` (index=0) →
|
||||
// `reasoning_summary_text.delta` →
|
||||
// `reasoning_summary_part.done` (index=0) →
|
||||
// (repeat for index>0) →
|
||||
// `output_item.done` (reasoning).
|
||||
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
|
||||
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
|
||||
// short-circuits when the entry already exists, and higher-index handlers
|
||||
// fold against the same entry. Behaviour for out-of-order events is
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const item = event.item
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
@@ -595,6 +632,103 @@ const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
||||
]
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
|
||||
if (event.summary_index === 0) {
|
||||
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:0`,
|
||||
openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const closed = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
openaiMetadata({ itemId: event.item_id }),
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
closed,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
|
||||
),
|
||||
),
|
||||
[event.summary_index]: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle:
|
||||
state.store !== false
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
openaiMetadata({ itemId: event.item_id }),
|
||||
)
|
||||
: state.lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* (
|
||||
state: ParserState,
|
||||
event: OpenAIResponsesEvent,
|
||||
@@ -655,6 +789,17 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
if (isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
const providerMetadata = reasoningMetadata(item)
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
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]}`, providerMetadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
|
||||
@@ -723,6 +868,10 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
event.type === "response.reasoning_summary_text.done"
|
||||
)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
if (event.type === "response.reasoning_summary_part.done")
|
||||
return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
@@ -749,7 +898,13 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIResponsesEvent),
|
||||
initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>(), lifecycle: Lifecycle.initial() }),
|
||||
initial: (request) => ({
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningItems: {},
|
||||
store: OpenAIOptions.store(request),
|
||||
}),
|
||||
step,
|
||||
terminal: (event) => TERMINAL_TYPES.has(event.type),
|
||||
},
|
||||
|
||||
@@ -24,16 +24,24 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
export const reasoningStart = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
): State => {
|
||||
if (state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
if (stepped.reasoning.has(id)) {
|
||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
||||
return stepped
|
||||
}
|
||||
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
|
||||
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
|
||||
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
||||
}
|
||||
|
||||
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const started = reasoningStart(state, events, id)
|
||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
||||
return started
|
||||
}
|
||||
|
||||
export const reasoningEnd = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
|
||||
@@ -1,73 +1,84 @@
|
||||
import { Schema } from "effect"
|
||||
import type { LLMRequest } from "../../schema"
|
||||
import { ReasoningEfforts, TextVerbosity, type ReasoningEffort } from "../../schema"
|
||||
import { ProviderOptions } from "./provider-options"
|
||||
import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema"
|
||||
import { ReasoningEfforts, TextVerbosity } from "../../schema"
|
||||
|
||||
export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
||||
(effort): effort is Exclude<ReasoningEffort, "max"> => effort !== "max",
|
||||
)
|
||||
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
||||
|
||||
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = [
|
||||
"file_search_call.results",
|
||||
"web_search_call.results",
|
||||
"web_search_call.action.sources",
|
||||
"message.input_image.image_url",
|
||||
"computer_call_output.output.image_url",
|
||||
"code_interpreter_call.outputs",
|
||||
"reasoning.encrypted_content",
|
||||
"message.output_text.logprobs",
|
||||
] as const
|
||||
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
|
||||
|
||||
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
||||
|
||||
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
||||
export const OpenAITextVerbosity = TextVerbosity
|
||||
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
||||
|
||||
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
||||
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
||||
|
||||
export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort =>
|
||||
typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort)
|
||||
|
||||
// Typed AI SDK OpenAI options. Mirrors the camelCase surface AI SDK accepts.
|
||||
// Known keys are typed; everything else passes through to the wire body with
|
||||
// its top-level key snake-cased.
|
||||
export interface Options {
|
||||
readonly store?: boolean
|
||||
readonly promptCacheKey?: string
|
||||
readonly promptCacheRetention?: string
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: string
|
||||
readonly textVerbosity?: "low" | "medium" | "high"
|
||||
readonly include?: ReadonlyArray<string>
|
||||
readonly includeEncryptedReasoning?: boolean
|
||||
readonly instructions?: string
|
||||
readonly conversation?: string
|
||||
readonly maxToolCalls?: number
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly parallelToolCalls?: boolean
|
||||
readonly previousResponseId?: string
|
||||
readonly safetyIdentifier?: string
|
||||
readonly serviceTier?: string
|
||||
readonly logprobs?: boolean | number
|
||||
readonly truncation?: string
|
||||
readonly user?: string
|
||||
readonly [extra: string]: unknown
|
||||
const isTextVerbosity = (value: unknown): value is TextVerbosityValue =>
|
||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
||||
|
||||
const options = (request: LLMRequest) => request.providerOptions?.openai
|
||||
|
||||
export const store = (request: LLMRequest): boolean | undefined => {
|
||||
const value = options(request)?.store
|
||||
return typeof value === "boolean" ? value : undefined
|
||||
}
|
||||
|
||||
export const KNOWN_KEYS: ReadonlySet<string> = new Set([
|
||||
"store",
|
||||
"promptCacheKey",
|
||||
"promptCacheRetention",
|
||||
"reasoningEffort",
|
||||
"reasoningSummary",
|
||||
"textVerbosity",
|
||||
"include",
|
||||
"includeEncryptedReasoning",
|
||||
"instructions",
|
||||
"conversation",
|
||||
"maxToolCalls",
|
||||
"metadata",
|
||||
"parallelToolCalls",
|
||||
"previousResponseId",
|
||||
"safetyIdentifier",
|
||||
"serviceTier",
|
||||
"logprobs",
|
||||
"truncation",
|
||||
"user",
|
||||
])
|
||||
export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => {
|
||||
const value = options(request)?.reasoningEffort
|
||||
return isAnyReasoningEffort(value) ? value : undefined
|
||||
}
|
||||
|
||||
// Read the merged `openai` provider option bag. Producers
|
||||
// (`packages/opencode/src/provider/transform.ts`) emit typed values; we widen
|
||||
// only the index signature so passthrough keys remain reachable. Invalid
|
||||
// shapes surface in the lowerer where they're consumed, not at decode time.
|
||||
export const options = (request: LLMRequest): Options => ProviderOptions.merge(request, ["openai"]) as Options
|
||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
|
||||
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
||||
|
||||
// Resolve the OpenAI Responses `include` field. Filters out unknown
|
||||
// includable values defensively so a typo in upstream config drops the
|
||||
// invalid entry instead of poisoning the wire body. An empty array (either
|
||||
// passed directly or produced by filtering) is treated as "no include" and
|
||||
// returns undefined so the request body omits the field entirely.
|
||||
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
|
||||
const value = options(request)?.include
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
|
||||
return filtered.length > 0 ? filtered : undefined
|
||||
}
|
||||
|
||||
export const promptCacheKey = (request: LLMRequest) => {
|
||||
const value = options(request)?.promptCacheKey
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export const textVerbosity = (request: LLMRequest) => {
|
||||
const value = options(request)?.textVerbosity
|
||||
return isTextVerbosity(value) ? value : undefined
|
||||
}
|
||||
|
||||
export const instructions = (request: LLMRequest) => {
|
||||
const value = options(request)?.instructions
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
export * as OpenAIOptions from "./openai-options"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { LLMRequest } from "../../schema"
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
// Convert a single top-level option key from camelCase to snake_case. Values
|
||||
// are left verbatim — recursive conversion would mangle structured payloads
|
||||
// (IDs, nested provider-shaped objects) and provider APIs do not require it.
|
||||
// PascalCase (`FooBar`) becomes `foo_bar` without a leading underscore.
|
||||
export const snakeKey = (key: string) =>
|
||||
key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase()
|
||||
|
||||
// Merge provider option namespaces using AI SDK precedence semantics: later
|
||||
// sources override earlier ones, missing namespaces are skipped. Used by every
|
||||
// native protocol that reads request-level provider options.
|
||||
export const merge = (request: LLMRequest, keys: ReadonlyArray<string>) => {
|
||||
const sources = keys.map((key) => request.providerOptions?.[key]).filter(isRecord)
|
||||
return Object.assign({}, ...sources) as Record<string, unknown>
|
||||
}
|
||||
|
||||
// Spread the unknown remainder of a merged option bag onto a provider body.
|
||||
// `consumed` lists keys already lowered explicitly so they aren't duplicated
|
||||
// or echoed at the wrong shape.
|
||||
export const passthrough = (options: Record<string, unknown>, consumed: ReadonlySet<string>) => {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
if (consumed.has(key)) continue
|
||||
if (value === undefined) continue
|
||||
result[snakeKey(key)] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export * as ProviderOptions from "./provider-options"
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
||||
import { mergeProviderOptions } from "../schema"
|
||||
import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
|
||||
|
||||
export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
|
||||
|
||||
export interface OpenAIOptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
@@ -7,7 +10,10 @@ export interface OpenAIOptionsInput {
|
||||
readonly promptCacheKey?: string
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: "auto"
|
||||
readonly includeEncryptedReasoning?: boolean
|
||||
// OpenAI Responses `include` wire field. Mirrors the official SDK's
|
||||
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
|
||||
// native-SDK callers share one shape and no translation is required.
|
||||
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
|
||||
readonly textVerbosity?: TextVerbosity
|
||||
}
|
||||
|
||||
@@ -25,7 +31,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
|
||||
promptCacheKey: options?.promptCacheKey,
|
||||
reasoningEffort: options?.reasoningEffort,
|
||||
reasoningSummary: options?.reasoningSummary,
|
||||
includeEncryptedReasoning: options?.includeEncryptedReasoning,
|
||||
include: options?.include,
|
||||
textVerbosity: options?.textVerbosity,
|
||||
}),
|
||||
)
|
||||
@@ -42,6 +48,12 @@ export const gpt5DefaultOptions = (
|
||||
return openAIProviderOptions({
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
// GPT-5 reasoning models are configured stateless (`store: false`) by
|
||||
// `openAIDefaultOptions` below, so the only way a follow-up turn can
|
||||
// carry reasoning state is via the encrypted reasoning include. Without
|
||||
// this, callers using the default model facade get reasoning summaries
|
||||
// they cannot replay statelessly.
|
||||
include: ["reasoning.encrypted_content"],
|
||||
textVerbosity:
|
||||
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
|
||||
? "low"
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export type { OpenAIOptionsInput } from "./openai-options"
|
||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("openai")
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
)
|
||||
return events.pipe(
|
||||
Stream.mapAccumEffect(
|
||||
protocol.stream.initial,
|
||||
() => protocol.stream.initial(request),
|
||||
protocol.stream.step,
|
||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||
),
|
||||
|
||||
@@ -52,8 +52,8 @@ export interface ProtocolBody<Body> {
|
||||
export interface ProtocolStream<Frame, Event, State> {
|
||||
/** Schema for one decoded streaming event, decoded from a transport frame. */
|
||||
readonly event: Schema.Codec<Event, Frame>
|
||||
/** Initial parser state. Called once per response. */
|
||||
readonly initial: () => State
|
||||
/** Initial parser state. Called once per response with the resolved request. */
|
||||
readonly initial: (request: LLMRequest) => State
|
||||
/** Translate one event into emitted `LLMEvent`s plus the next state. */
|
||||
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
|
||||
/** Optional request-completion signal for transports that do not end naturally. */
|
||||
|
||||
@@ -97,7 +97,7 @@ export function continuationRequest(input: {
|
||||
tools: features.has("tool-call") ? [continuationTool] : [],
|
||||
cache: "none",
|
||||
providerOptions: features.has("encrypted-reasoning")
|
||||
? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } }
|
||||
? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } }
|
||||
: undefined,
|
||||
generation: { maxTokens: 80, temperature: 0 },
|
||||
})
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -209,80 +209,6 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers Anthropic thinking provider option (enabled)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "think",
|
||||
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 12345 } } },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({ thinking: { type: "enabled", budget_tokens: 12345 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers Anthropic adaptive thinking with effort sibling", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "think",
|
||||
providerOptions: { anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "max" } },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "max",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sets per-tool eager_input_streaming only when toolStreaming is true", () =>
|
||||
Effect.gen(function* () {
|
||||
const off = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "use tool",
|
||||
providerOptions: { anthropic: { toolStreaming: false } },
|
||||
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
)
|
||||
expect(off.body.tools?.[0]?.eager_input_streaming).toBeUndefined()
|
||||
|
||||
const on = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "use tool",
|
||||
providerOptions: { anthropic: { toolStreaming: true } },
|
||||
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
)
|
||||
expect(on.body.tools?.[0]?.eager_input_streaming).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes unknown Anthropic provider options through with snake-cased keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "go",
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
anthropicBeta: ["claude-2024-07-15"],
|
||||
customField: { keepCamelCase: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({
|
||||
anthropic_beta: ["claude-2024-07-15"],
|
||||
custom_field: { keepCamelCase: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Auth, LLMClient } from "../../src/route"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
const Json = Schema.fromJsonString(Schema.Unknown)
|
||||
@@ -199,134 +199,6 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through compatible options and prior reasoning for tool continuations", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
providerOptions: {
|
||||
deepseek: {
|
||||
reasoningEffort: "max",
|
||||
textVerbosity: "low",
|
||||
promptCacheKey: "session_123",
|
||||
strictJsonSchema: false,
|
||||
enable_thinking: true,
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
Message.user("Audit the site"),
|
||||
Message.make({
|
||||
role: "assistant",
|
||||
native: { openaiCompatible: { reasoning_content: "I should inspect the page." } },
|
||||
content: [ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "page" } })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning_effort: "max",
|
||||
verbosity: "low",
|
||||
prompt_cache_key: "session_123",
|
||||
enable_thinking: true,
|
||||
messages: [
|
||||
{ role: "user", content: "Audit the site" },
|
||||
{
|
||||
role: "assistant",
|
||||
reasoning_content: "I should inspect the page.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
function: { name: "lookup", arguments: '{"query":"page"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(prepared.body).not.toHaveProperty("strictJsonSchema")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves structured reasoning_details on compatible continuations", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.text", text: "Let me work through this.", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "sha256:abc123", format: "anthropic-claude-v1", index: 1 },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.make({
|
||||
role: "assistant",
|
||||
native: { openaiCompatible: { reasoning_details: details } },
|
||||
content: [ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
messages: [{ role: "assistant", reasoning_details: details }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves dot-scoped compatible provider options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: OpenAICompatibleChat.route
|
||||
.with({ provider: "opencode.internal", endpoint: { baseURL: "https://api.example.test/v1" } })
|
||||
.model({ id: "reasoning-model" }),
|
||||
prompt: "Think.",
|
||||
providerOptions: { opencode: { reasoningEffort: "max", enable_thinking: true } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ reasoning_effort: "max", enable_thinking: true })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not apply OpenAI effort limits to compatible providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: OpenAICompatibleChat.route
|
||||
.with({ provider: "openai", endpoint: { baseURL: "https://compatible.example.test/v1" } })
|
||||
.model({ id: "reasoning-model" }),
|
||||
prompt: "Think.",
|
||||
providerOptions: { openai: { reasoningEffort: "max" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ reasoning_effort: "max" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses compatible reasoning field variants", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ reasoning: "fallback" }),
|
||||
deltaChunk({
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: " text-detail", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.summary", summary: " summary-detail", format: "anthropic-claude-v1", index: 1 },
|
||||
],
|
||||
}),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("fallback text-detail summary-detail")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -393,7 +393,7 @@ describe("OpenAI Responses route", () => {
|
||||
promptCacheKey: "session_123",
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
includeEncryptedReasoning: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -407,27 +407,105 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes unknown OpenAI provider options through with snake-cased keys", () =>
|
||||
it.effect("accepts the full ResponseIncludable union", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||
prompt: "passthrough",
|
||||
model,
|
||||
prompt: "hi",
|
||||
providerOptions: {
|
||||
openai: {
|
||||
customCamelCaseField: "value",
|
||||
already_snake_case: 42,
|
||||
nested: { keepCamelCase: true },
|
||||
include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
custom_camel_case_field: "value",
|
||||
already_snake_case: 42,
|
||||
nested: { keepCamelCase: true },
|
||||
})
|
||||
expect(prepared.body.include).toEqual([
|
||||
"reasoning.encrypted_content",
|
||||
"code_interpreter_call.outputs",
|
||||
"web_search_call.results",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters unknown includable values out of the include array", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
// The user passed one invalid entry alongside a valid one. Keep the
|
||||
// valid one so the request still succeeds rather than failing on a
|
||||
// typo from upstream config.
|
||||
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an explicit empty include as no include at all", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an all-invalid include as no include at all", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits include when no include is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
|
||||
Effect.gen(function* () {
|
||||
// The native OpenAI facade configures GPT-5 stateless (store: false) with
|
||||
// reasoningSummary: "auto" by default. Without `include`, a follow-up
|
||||
// turn cannot replay reasoning state, so the facade also opts into
|
||||
// `reasoning.encrypted_content` automatically.
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets callers opt out of the GPT-5 default include", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
|
||||
prompt: "hi",
|
||||
providerOptions: { openai: { include: [] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -571,6 +649,94 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("FirstSecond")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a stateless reasoning conversation", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
@@ -594,6 +760,7 @@ describe("OpenAI Responses route", () => {
|
||||
]),
|
||||
Message.user("Summarize it."),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
@@ -651,6 +818,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "text", text: "After." },
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -667,6 +835,66 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored reasoning items by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked the previous diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: true } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
id: "req_multi_summary_continuation",
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "First",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Second",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
{ type: "summary_text", text: "Second" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
|
||||
@@ -158,7 +158,7 @@ const normalizeImageText = (value: string) =>
|
||||
const encryptedReasoningOptions = {
|
||||
openai: {
|
||||
store: false,
|
||||
includeEncryptedReasoning: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice }
|
||||
import { Auth, LLMClient } from "../src/route"
|
||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses"
|
||||
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
|
||||
import { ToolRuntime } from "../src/tool-runtime"
|
||||
import { it } from "./lib/effect"
|
||||
@@ -309,6 +310,80 @@ describe("LLMClient tools", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const bodies: unknown[] = []
|
||||
const layer = dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
bodies.push(decodeJson(input.text))
|
||||
return input.respond(
|
||||
bodies.length === 1
|
||||
? sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "get_weather",
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "get_weather",
|
||||
arguments: '{"city":"Paris"}',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: {} },
|
||||
)
|
||||
: sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
|
||||
{ type: "response.completed", response: {} },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* TestToolRuntime.runTools({
|
||||
request: LLM.request({
|
||||
model: OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-5.5" }),
|
||||
prompt: "Use the tool.",
|
||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
}),
|
||||
tools: { get_weather },
|
||||
}).pipe(Stream.runCollect, Effect.provide(layer))
|
||||
|
||||
expect(bodies[1]).toMatchObject({
|
||||
include: ["reasoning.encrypted_content"],
|
||||
input: [
|
||||
{ role: "user" },
|
||||
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
|
||||
{ type: "function_call", call_id: "call_1", name: "get_weather" },
|
||||
{ type: "function_call_output", call_id: "call_1" },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
|
||||
Effect.gen(function* () {
|
||||
const layer = scriptedResponses([
|
||||
|
||||
@@ -74,15 +74,15 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/google": "3.0.75",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Effect, Layer, Option, Schema, Context } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Identifier } from "@/id/id"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import os from "os"
|
||||
@@ -253,17 +253,20 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
tool_output: Schema.optional(
|
||||
Schema.Struct({
|
||||
max_lines: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
|
||||
Schema.Union([
|
||||
Schema.Literal(false).annotate({ description: "Disable tool output truncation" }),
|
||||
Schema.Struct({
|
||||
max_lines: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
|
||||
}),
|
||||
max_bytes: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
|
||||
}),
|
||||
}),
|
||||
max_bytes: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
).annotate({
|
||||
description:
|
||||
"Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
|
||||
"Configure tool output truncation. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
|
||||
}),
|
||||
compaction: Schema.optional(
|
||||
Schema.Struct({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Database } from "@/storage/db"
|
||||
import { asc } from "drizzle-orm"
|
||||
|
||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
type State = Record<string, string | undefined>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Layer, Schema, Context, Stream } from "effect"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { errorMessage } from "@/util/error"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "path"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect, Layer, Context, Option, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { disposeInstance as runDisposers } from "@/effect/instance-registry"
|
||||
|
||||
@@ -20,7 +20,7 @@ import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Project as ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const log = Log.create({ service: "project" })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { type LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import * as ModelsDev from "@opencode-ai/core/models-dev"
|
||||
import { Auth } from "../auth"
|
||||
|
||||
@@ -17,6 +17,11 @@ function mimeToModality(mime: string): Modality | undefined {
|
||||
|
||||
export const OUTPUT_TOKEN_MAX = 32_000
|
||||
|
||||
// OpenAI Responses `include` value that returns the encrypted reasoning state
|
||||
// needed for stateless multi-turn reasoning (store: false). Hoisted so every
|
||||
// branch that requests it stays in lockstep.
|
||||
const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
|
||||
|
||||
export function sanitizeSurrogates(content: string) {
|
||||
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
|
||||
}
|
||||
@@ -756,7 +761,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
{
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
include: INCLUDE_ENCRYPTED_REASONING,
|
||||
},
|
||||
]),
|
||||
)
|
||||
@@ -790,7 +795,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
{
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
include: INCLUDE_ENCRYPTED_REASONING,
|
||||
},
|
||||
]),
|
||||
)
|
||||
@@ -803,7 +808,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
{
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
include: INCLUDE_ENCRYPTED_REASONING,
|
||||
},
|
||||
]),
|
||||
)
|
||||
@@ -1134,6 +1139,9 @@ export function options(input: {
|
||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||
result["reasoningEffort"] = "medium"
|
||||
result["reasoningSummary"] = "auto"
|
||||
if (input.model.api.npm === "@ai-sdk/openai") {
|
||||
result["include"] = INCLUDE_ENCRYPTED_REASONING
|
||||
}
|
||||
}
|
||||
|
||||
// Only set textVerbosity for non-chat gpt-5.x models
|
||||
@@ -1149,7 +1157,7 @@ export function options(input: {
|
||||
|
||||
if (input.model.providerID.startsWith("opencode")) {
|
||||
result["promptCacheKey"] = input.sessionID
|
||||
result["include"] = ["reasoning.encrypted_content"]
|
||||
result["include"] = INCLUDE_ENCRYPTED_REASONING
|
||||
result["reasoningSummary"] = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Effect, Layer, Context, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow, usable } from "./overflow"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
|
||||
@@ -110,9 +110,7 @@ const messages = (input: readonly ModelMessage[]) => {
|
||||
Message.make({
|
||||
role: message.role,
|
||||
content: content(message.content),
|
||||
// Message provider options are already provider-native wire metadata
|
||||
// (for example DeepSeek's reasoning_content continuation field).
|
||||
native: isRecord(message.providerOptions) ? message.providerOptions : undefined,
|
||||
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
@@ -70,6 +70,14 @@ export function stream(input: StreamInput): StreamResult {
|
||||
|
||||
// Integration point with @opencode-ai/llm: native-request lowers session data
|
||||
// into an LLMRequest, then LLMClient handles route selection and transport.
|
||||
//
|
||||
// ProviderTransform.providerOptions builds AI-SDK-shaped options for the
|
||||
// selected SDK key (e.g. "openai") and the native LLM SDK reads the same
|
||||
// keys via OpenAIOptions.* (store, reasoningEffort, reasoningSummary,
|
||||
// include, textVerbosity, promptCacheKey). Both sides intentionally use
|
||||
// OpenAI's official wire field names, so this is identity, not translation
|
||||
// — if a field ever needs to differ between the two surfaces, the
|
||||
// translation belongs here, not split across both packages.
|
||||
const stream = input.llmClient.stream({
|
||||
request: LLMNative.request({
|
||||
model: input.model,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import path from "path"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type * as SDK from "@opencode-ai/sdk/v2"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Account } from "@/account/account"
|
||||
|
||||
@@ -23,6 +23,10 @@ shape before writing config, **fetch that URL and read the schema directly**
|
||||
rather than guessing. opencode hard-fails on invalid config, so the cost of a
|
||||
wrong shape is a broken startup.
|
||||
|
||||
The full schema is large. Prefer using JavaScript or Bash to fetch and extract
|
||||
the relevant property definition instead of reading the entire schema into
|
||||
context when you only need to check one setting.
|
||||
|
||||
Independently, every `opencode.json` should declare
|
||||
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
|
||||
mistakes as they type.
|
||||
|
||||
@@ -12,7 +12,7 @@ import { EventID } from "./schema"
|
||||
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect, Option, Stream } from "effect"
|
||||
import os from "os"
|
||||
import { createWriteStream } from "node:fs"
|
||||
import * as Tool from "./tool"
|
||||
@@ -433,7 +433,10 @@ export const ShellTool = Tool.define(
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
const limits = yield* trunc.limits()
|
||||
const keep = limits.maxBytes * 2
|
||||
const keep = Option.match(limits, {
|
||||
onNone: () => Number.POSITIVE_INFINITY,
|
||||
onSome: (l) => l.maxBytes * 2,
|
||||
})
|
||||
let full = ""
|
||||
let last = ""
|
||||
const list: Chunk[] = []
|
||||
@@ -499,7 +502,7 @@ export const ShellTool = Tool.define(
|
||||
sink?.write(chunk)
|
||||
} else {
|
||||
full += chunk
|
||||
if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
|
||||
if (Option.isSome(limits) && Buffer.byteLength(full, "utf-8") > limits.value.maxBytes) {
|
||||
return trunc.write(full).pipe(
|
||||
Effect.andThen((next) =>
|
||||
Effect.sync(() => {
|
||||
@@ -566,7 +569,10 @@ export const ShellTool = Tool.define(
|
||||
}
|
||||
if (aborted) meta.push("User aborted the command")
|
||||
const raw = list.map((item) => item.text).join("")
|
||||
const end = tail(raw, limits.maxLines, limits.maxBytes)
|
||||
const end = Option.match(limits, {
|
||||
onNone: () => ({ text: raw, cut: false }),
|
||||
onSome: (l) => tail(raw, l.maxLines, l.maxBytes),
|
||||
})
|
||||
if (end.cut) cut = true
|
||||
if (!file && end.cut) {
|
||||
file = yield* trunc.write(raw)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import { Option, Schema } from "effect"
|
||||
import DESCRIPTION from "./shell.txt"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -83,7 +83,12 @@ function chainGuidance(name: string) {
|
||||
return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
}
|
||||
|
||||
function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
|
||||
function truncationGuidance(limits: Option.Option<Limits>, commands: string) {
|
||||
if (Option.isNone(limits)) return ""
|
||||
return `\n - If the output exceeds ${limits.value.maxLines} lines or ${limits.value.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use ${commands} to limit output; the full output will already be captured to a file for more precise searching.`
|
||||
}
|
||||
|
||||
function bashCommandSection(chain: string, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
|
||||
return `Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
@@ -103,8 +108,7 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`head`, `tail`, or other truncation commands")}
|
||||
|
||||
- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT find or ls)
|
||||
@@ -131,7 +135,7 @@ function powershellCommandSection(
|
||||
name: string,
|
||||
chain: string,
|
||||
pathSep: string,
|
||||
limits: Limits,
|
||||
limits: Option.Option<Limits>,
|
||||
defaultTimeoutMs: number,
|
||||
) {
|
||||
return `${powershellNotes(name)}
|
||||
@@ -155,8 +159,7 @@ Before executing the command, please follow these steps:
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`Select-Object -First`, `Select-Object -Last`, or other truncation commands")}
|
||||
|
||||
- Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT Get-ChildItem)
|
||||
@@ -179,7 +182,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function cmdCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
|
||||
function cmdCommandSection(chain: string, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
|
||||
return `# cmd.exe shell notes
|
||||
- Use double quotes for paths with spaces.
|
||||
- Use %VAR% for environment variables.
|
||||
@@ -205,8 +208,7 @@ Before executing the command, please follow these steps:
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`more` or other pagination commands")}
|
||||
|
||||
- Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT dir /s)
|
||||
@@ -229,7 +231,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
|
||||
const isPowerShell = PS.has(name)
|
||||
const chain = chainGuidance(name)
|
||||
if (CMD.has(name)) {
|
||||
@@ -284,7 +286,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
|
||||
}
|
||||
}
|
||||
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
|
||||
const selected = profile(name, platform, limits, defaultTimeoutMs)
|
||||
return {
|
||||
description: renderPrompt(DESCRIPTION, {
|
||||
|
||||
@@ -19,6 +19,7 @@ export const DIR = TRUNCATION_DIR
|
||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
||||
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
export type Limits = { maxLines: number; maxBytes: number }
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
@@ -40,9 +41,11 @@ export interface Interface {
|
||||
*/
|
||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||
/**
|
||||
* Resolved truncation limits: values from `tool_output` in opencode config, or MAX_LINES / MAX_BYTES if unset.
|
||||
* Resolved truncation limits from `tool_output` in opencode config.
|
||||
* Returns `None` when the user has disabled truncation (`tool_output: false`),
|
||||
* in which case callers should pass output through without enforcing thresholds.
|
||||
*/
|
||||
readonly limits: () => Effect.Effect<{ maxLines: number; maxBytes: number }>
|
||||
readonly limits: () => Effect.Effect<Option.Option<Limits>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||
@@ -75,18 +78,21 @@ export const layer = Layer.effect(
|
||||
|
||||
const limits = Effect.fn("Truncate.limits")(function* () {
|
||||
const configSvc = yield* Effect.serviceOption(Config.Service)
|
||||
if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
|
||||
if (Option.isNone(configSvc)) return Option.some({ maxLines: MAX_LINES, maxBytes: MAX_BYTES })
|
||||
const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return {
|
||||
maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
|
||||
maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
|
||||
}
|
||||
const tool_output = cfg?.tool_output
|
||||
if (tool_output === false) return Option.none<Limits>()
|
||||
return Option.some({
|
||||
maxLines: tool_output?.max_lines ?? MAX_LINES,
|
||||
maxBytes: tool_output?.max_bytes ?? MAX_BYTES,
|
||||
})
|
||||
})
|
||||
|
||||
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||
const resolved = yield* limits()
|
||||
const maxLines = options.maxLines ?? resolved.maxLines
|
||||
const maxBytes = options.maxBytes ?? resolved.maxBytes
|
||||
if (Option.isNone(resolved)) return { content: text, truncated: false } as const
|
||||
const maxLines = options.maxLines ?? resolved.value.maxLines
|
||||
const maxBytes = options.maxBytes ?? resolved.value.maxBytes
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+5
-5
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
@@ -271,6 +271,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
|
||||
const model = createGpt5Model("gpt-5.2")
|
||||
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
|
||||
expect(result.textVerbosity).toBe("low")
|
||||
expect(result.include).toEqual(["reasoning.encrypted_content"])
|
||||
})
|
||||
|
||||
test("gpt-5.1 should have textVerbosity set to low", () => {
|
||||
|
||||
@@ -336,10 +336,25 @@ const weatherTool = tool({
|
||||
})
|
||||
|
||||
const toolRoundtrip = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
call: { readonly id: string; readonly name: string; readonly input: unknown },
|
||||
result: JSONValue,
|
||||
): ModelMessage[] => [
|
||||
{ role: "assistant", content: [{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
...events.filter(LLMEvent.is.reasoningEnd).map((part) => ({
|
||||
type: "reasoning" as const,
|
||||
text: events
|
||||
.filter(LLMEvent.is.reasoningDelta)
|
||||
.filter((event) => event.id === part.id)
|
||||
.map((event) => event.text)
|
||||
.join(""),
|
||||
providerMetadata: part.providerMetadata,
|
||||
})),
|
||||
{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -395,7 +410,7 @@ const driveToolLoop = (scenario: RecordedScenario) =>
|
||||
|
||||
const turn2 = yield* collect({
|
||||
...base,
|
||||
messages: [userMessage, ...toolRoundtrip(toolCall!, WEATHER_RESULT)],
|
||||
messages: [userMessage, ...toolRoundtrip(turn1, toolCall!, WEATHER_RESULT)],
|
||||
})
|
||||
|
||||
expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Effect, Layer, Stream } from "effect"
|
||||
import { LLMNative } from "@/session/llm/native-request"
|
||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { OAUTH_DUMMY_KEY } from "@/auth"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -71,21 +70,6 @@ const providerInfo: Provider.Info = {
|
||||
models: {},
|
||||
}
|
||||
|
||||
const compatibleModel: Provider.Model = {
|
||||
...baseModel,
|
||||
id: ModelID.make("deepseek-v4-flash-free"),
|
||||
providerID: ProviderID.make("opencode"),
|
||||
api: {
|
||||
id: "deepseek-v4-flash-free",
|
||||
url: "https://ai.example.test/v1",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
capabilities: {
|
||||
...baseModel.capabilities,
|
||||
interleaved: { field: "reasoning_content" },
|
||||
},
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
|
||||
)
|
||||
@@ -342,70 +326,6 @@ describe("session.llm-native.request", () => {
|
||||
])
|
||||
})
|
||||
|
||||
it.effect("preserves OpenAI-compatible reasoning continuation and provider options", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages = ProviderTransform.message(
|
||||
[
|
||||
{ role: "user", content: "Audit the site" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "I should inspect the page." },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "devtools_new_page",
|
||||
input: { url: "https://example.test" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as ModelMessage[],
|
||||
compatibleModel,
|
||||
{},
|
||||
)
|
||||
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLMNative.request({
|
||||
model: compatibleModel,
|
||||
apiKey: "test-key",
|
||||
messages,
|
||||
providerOptions: ProviderTransform.providerOptions(compatibleModel, {
|
||||
reasoningEffort: "max",
|
||||
textVerbosity: "low",
|
||||
promptCacheKey: "session-1",
|
||||
enable_thinking: true,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "deepseek-v4-flash-free",
|
||||
reasoning_effort: "max",
|
||||
verbosity: "low",
|
||||
prompt_cache_key: "session-1",
|
||||
enable_thinking: true,
|
||||
messages: [
|
||||
{ role: "user", content: "Audit the site" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning_content: "I should inspect the page.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "devtools_new_page",
|
||||
arguments: '{"url":"https://example.test"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("selects native request routes for provider packages", () => {
|
||||
const openai = LLMNative.model({
|
||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
||||
@@ -671,7 +591,7 @@ describe("session.llm-native.request", () => {
|
||||
]),
|
||||
storedSession.user("Summarize it."),
|
||||
],
|
||||
providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
|
||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
expectedBody: {
|
||||
input: [
|
||||
openAIResponses.user("What changed?"),
|
||||
@@ -688,6 +608,45 @@ describe("session.llm-native.request", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves empty encrypted OpenAI reasoning items before tool output", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [
|
||||
storedSession.assistant([
|
||||
storedSession.openaiReasoning("", {
|
||||
storedAs: "providerMetadata",
|
||||
itemId: "rs_1",
|
||||
encryptedContent: "encrypted-state",
|
||||
}),
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
expectedBody: {
|
||||
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored OpenAI reasoning items by id", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [
|
||||
storedSession.assistant([
|
||||
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||
storedAs: "providerMetadata",
|
||||
itemId: "rs_1",
|
||||
encryptedContent: null,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: true } },
|
||||
expectedBody: {
|
||||
input: [{ type: "item_reference", id: "rs_1" }],
|
||||
store: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const captures: Array<{ url: string; body: unknown }> = []
|
||||
|
||||
@@ -1166,6 +1166,7 @@ describe("session.llm.stream", () => {
|
||||
expect(capture.body.model).toBe(model.id)
|
||||
expect(capture.body.stream).toBe(true)
|
||||
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
|
||||
expect(capture.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
|
||||
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
|
||||
}),
|
||||
|
||||
@@ -1205,6 +1205,29 @@ describe("tool.shell truncation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not truncate output when tool_output is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ config: { tool_output: false } })
|
||||
yield* runIn(
|
||||
tmp,
|
||||
Effect.gen(function* () {
|
||||
const bash = yield* initShell()
|
||||
expect(bash.description).not.toContain("If the output exceeds")
|
||||
const result = yield* bash.execute(
|
||||
{
|
||||
command: fill("bytes", Truncate.MAX_BYTES + 10000),
|
||||
description: "Generate bytes with truncation disabled",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.output).not.toContain("...output truncated...")
|
||||
expect(Buffer.byteLength(result.output, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("full output is saved to file when truncated", () =>
|
||||
runIn(
|
||||
projectRoot,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Effect, FileSystem, Layer, Option } from "effect"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Config } from "@/config/config"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
@@ -110,8 +110,11 @@ describe("Truncate", () => {
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Truncate.Service
|
||||
const resolved = yield* svc.limits()
|
||||
expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
|
||||
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
|
||||
expect(Option.isSome(resolved)).toBe(true)
|
||||
if (Option.isSome(resolved)) {
|
||||
expect(resolved.value.maxLines).toBe(Truncate.MAX_LINES)
|
||||
expect(resolved.value.maxBytes).toBe(Truncate.MAX_BYTES)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -120,8 +123,11 @@ describe("Truncate", () => {
|
||||
limitsIt.live("limits() reflects config overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* (yield* Truncate.Service).limits()
|
||||
expect(resolved.maxLines).toBe(123)
|
||||
expect(resolved.maxBytes).toBe(456)
|
||||
expect(Option.isSome(resolved)).toBe(true)
|
||||
if (Option.isSome(resolved)) {
|
||||
expect(resolved.value.maxLines).toBe(123)
|
||||
expect(resolved.value.maxBytes).toBe(456)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -159,6 +165,18 @@ describe("Truncate", () => {
|
||||
expect(result.truncated).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
const disabledIt = configuredIt({ tool_output: false })
|
||||
disabledIt.live("does not truncate output when disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const content = "a".repeat(Truncate.MAX_BYTES + 1)
|
||||
const svc = yield* Truncate.Service
|
||||
const resolved = yield* svc.limits()
|
||||
const result = yield* svc.output(content)
|
||||
expect(Option.isNone(resolved)).toBe(true)
|
||||
expect(result).toEqual({ content, truncated: false })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("large single-line file truncates with byte message", () =>
|
||||
|
||||
@@ -1294,10 +1294,15 @@ export type Config = {
|
||||
enterprise?: {
|
||||
url?: string
|
||||
}
|
||||
tool_output?: {
|
||||
max_lines?: number
|
||||
max_bytes?: number
|
||||
}
|
||||
/**
|
||||
* Configure tool output truncation. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.
|
||||
*/
|
||||
tool_output?:
|
||||
| false
|
||||
| {
|
||||
max_lines?: number
|
||||
max_bytes?: number
|
||||
}
|
||||
compaction?: {
|
||||
auto?: boolean
|
||||
prune?: boolean
|
||||
|
||||
@@ -353,6 +353,36 @@ You can manage the tools an LLM can use through the `tools` option.
|
||||
|
||||
---
|
||||
|
||||
### Tool output
|
||||
|
||||
You can control when tool output is truncated using the `tool_output` option. When output exceeds either threshold, OpenCode saves the complete output to disk and returns a truncated preview with the saved file path.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tool_output": {
|
||||
"max_lines": 2000,
|
||||
"max_bytes": 51200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `max_lines` - Maximum number of lines before output is truncated (default: `2000`).
|
||||
- `max_bytes` - Maximum size in bytes before output is truncated (default: `51200`).
|
||||
|
||||
These thresholds apply to output handled by OpenCode's shared truncation layer, including MCP and plugin tool output. Individual tools that page or cap their own results can have separate limits.
|
||||
|
||||
To disable shared tool output truncation, set `tool_output` to `false`:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tool_output": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Models
|
||||
|
||||
You can configure the providers and models you want to use in your OpenCode config through the `provider`, `model` and `small_model` options.
|
||||
|
||||
Reference in New Issue
Block a user