mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 22:48:16 +00:00
Merge branch 'dev' into fix/acp-show-proper-run-command-message
This commit is contained in:
12
.github/workflows/beta.yml
vendored
12
.github/workflows/beta.yml
vendored
@@ -1,21 +1,15 @@
|
||||
name: beta
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
pull_request:
|
||||
types: [opened, synchronize, labeled, unlabeled]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: |
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'contributor'))
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
3
.github/workflows/test.yml
vendored
3
.github/workflows/test.yml
vendored
@@ -1,6 +1,9 @@
|
||||
name: test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
|
||||
66
bun.lock
66
bun.lock
@@ -266,25 +266,25 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.73",
|
||||
"@ai-sdk/anthropic": "2.0.57",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.74",
|
||||
"@ai-sdk/anthropic": "2.0.58",
|
||||
"@ai-sdk/azure": "2.0.91",
|
||||
"@ai-sdk/cerebras": "1.0.34",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@ai-sdk/cohere": "2.0.22",
|
||||
"@ai-sdk/deepinfra": "1.0.31",
|
||||
"@ai-sdk/gateway": "2.0.25",
|
||||
"@ai-sdk/deepinfra": "1.0.33",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.52",
|
||||
"@ai-sdk/google-vertex": "3.0.97",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
"@ai-sdk/openai-compatible": "1.0.30",
|
||||
"@ai-sdk/openai-compatible": "1.0.32",
|
||||
"@ai-sdk/perplexity": "2.0.23",
|
||||
"@ai-sdk/provider": "2.0.1",
|
||||
"@ai-sdk/provider-utils": "3.0.20",
|
||||
"@ai-sdk/togetherai": "1.0.31",
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@ai-sdk/togetherai": "1.0.34",
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.56",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -297,7 +297,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.75",
|
||||
"@opentui/solid": "0.1.75",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
@@ -521,7 +521,7 @@
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "5.0.119",
|
||||
"ai": "5.0.124",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -559,23 +559,23 @@
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@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-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="],
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@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-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
|
||||
|
||||
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
|
||||
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="],
|
||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
|
||||
|
||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
|
||||
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="],
|
||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.97", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
|
||||
|
||||
@@ -591,11 +591,11 @@
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
|
||||
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="],
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
@@ -1221,7 +1221,7 @@
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
|
||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
|
||||
|
||||
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
|
||||
|
||||
@@ -1909,7 +1909,7 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
@@ -1947,7 +1947,7 @@
|
||||
|
||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||
|
||||
"ai": ["ai@5.0.119", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="],
|
||||
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
@@ -3975,7 +3975,9 @@
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
|
||||
|
||||
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
@@ -3983,11 +3985,11 @@
|
||||
|
||||
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
|
||||
|
||||
@@ -3997,11 +3999,11 @@
|
||||
|
||||
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
@@ -4381,11 +4383,11 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
|
||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-gUWzUsk81miIrjg0fZQmsIQG4pZYmEHgzN6BaXI+lfc=",
|
||||
"aarch64-linux": "sha256-gwEG75ha/ojTO2iAObTmLTtEkXIXJ7BThzfI5CqlJh8=",
|
||||
"aarch64-darwin": "sha256-20RGG2GkUItCzD67gDdoSLfexttM8abS//FKO9bfjoM=",
|
||||
"x86_64-darwin": "sha256-i2VawFuR1UbjPVYoybU6aJDJfFo0tcvtl1aM31Y2mTQ="
|
||||
"x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=",
|
||||
"aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=",
|
||||
"aarch64-darwin": "sha256-pWfXomWTDvG8WpWmUCwNXdbSHw6hPlqoT0Q/XuNceMc=",
|
||||
"x86_64-darwin": "sha256-Dmg4+cUq2r6vZB2ta9tLpNAWqcl11ZCu4ZpieegRFrY="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"ai": "5.0.119",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
|
||||
@@ -4,6 +4,17 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { execSync } from "node:child_process"
|
||||
import { modKey, serverUrl } from "./utils"
|
||||
import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
titlebarRightSelector,
|
||||
popoverBodySelector,
|
||||
listItemSelector,
|
||||
listItemKeySelector,
|
||||
listItemKeyStartsWithSelector,
|
||||
} from "./selectors"
|
||||
import type { createSdk } from "./utils"
|
||||
|
||||
export async function defocus(page: Page) {
|
||||
await page.mouse.click(5, 5)
|
||||
@@ -158,3 +169,103 @@ export function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
||||
await expect(sessionEl).toBeVisible()
|
||||
await sessionEl.hover()
|
||||
return sessionEl
|
||||
}
|
||||
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
const sessionEl = await hoverSessionItem(page, sessionID)
|
||||
|
||||
const menuTrigger = sessionEl.locator(dropdownMenuTriggerSelector).first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click()
|
||||
|
||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||
await expect(menu).toBeVisible()
|
||||
return menu
|
||||
}
|
||||
|
||||
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
|
||||
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.click({ force: options?.force })
|
||||
}
|
||||
|
||||
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||
const dialog = page.getByRole("dialog").first()
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
export async function openSharePopover(page: Page) {
|
||||
const rightSection = page.locator(titlebarRightSelector)
|
||||
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
||||
await expect(shareButton).toBeVisible()
|
||||
|
||||
const popoverBody = page
|
||||
.locator(popoverBodySelector)
|
||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||
.first()
|
||||
|
||||
const opened = await popoverBody
|
||||
.isVisible()
|
||||
.then((x) => x)
|
||||
.catch(() => false)
|
||||
|
||||
if (!opened) {
|
||||
await shareButton.click()
|
||||
await expect(popoverBody).toBeVisible()
|
||||
}
|
||||
return { rightSection, popoverBody }
|
||||
}
|
||||
|
||||
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
||||
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
export async function clickListItem(
|
||||
container: Locator | Page,
|
||||
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
|
||||
): Promise<Locator> {
|
||||
let item: Locator
|
||||
|
||||
if (typeof filter === "string" || filter instanceof RegExp) {
|
||||
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
|
||||
} else if (filter.keyStartsWith) {
|
||||
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
|
||||
} else if (filter.key) {
|
||||
item = container.locator(listItemKeySelector(filter.key)).first()
|
||||
} else if (filter.text) {
|
||||
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
|
||||
} else {
|
||||
throw new Error("Invalid filter provided to clickListItem")
|
||||
}
|
||||
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
return item
|
||||
}
|
||||
|
||||
export async function withSession<T>(
|
||||
sdk: ReturnType<typeof createSdk>,
|
||||
title: string,
|
||||
callback: (session: { id: string; title: string }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||
if (!session?.id) throw new Error("Session create did not return an id")
|
||||
|
||||
try {
|
||||
return await callback(session)
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { serverName, serverUrl } from "../utils"
|
||||
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
||||
|
||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||
|
||||
@@ -33,31 +34,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
const menu = row.locator('[data-component="icon-button"]').last()
|
||||
await menu.click()
|
||||
await page.getByRole("menuitem", { name: "Set as default" }).click()
|
||||
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click({ force: true })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
await clickMenuItem(menu, /set as default/i)
|
||||
|
||||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
const closed = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!closed) {
|
||||
await page.keyboard.press("Escape")
|
||||
const closedSecond = await dialog
|
||||
.waitFor({ state: "detached", timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
if (!closedSecond) {
|
||||
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||
await expect(dialog).toHaveCount(0)
|
||||
}
|
||||
}
|
||||
await closeDialog(page, dialog)
|
||||
|
||||
await ensurePopoverOpen()
|
||||
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke ${Date.now()}`
|
||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
||||
|
||||
if (!created?.id) throw new Error("Session create did not return an id")
|
||||
const sessionID = created.id
|
||||
|
||||
try {
|
||||
await gotoSession(sessionID)
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("hello from e2e")
|
||||
await expect(prompt).toContainText("hello from e2e")
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,42 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const stamp = Date.now()
|
||||
const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data)
|
||||
const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data)
|
||||
|
||||
if (!one?.id) throw new Error("Session create did not return an id")
|
||||
if (!two?.id) throw new Error("Session create did not return an id")
|
||||
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
|
||||
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
|
||||
await gotoSession(one.id)
|
||||
|
||||
try {
|
||||
await gotoSession(one.id)
|
||||
await openSidebar(page)
|
||||
|
||||
await openSidebar(page)
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
|
||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||
await expect(link).toBeVisible()
|
||||
await link.scrollIntoViewIfNeeded()
|
||||
await link.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
const back = page.getByRole("button", { name: "Back" })
|
||||
const forward = page.getByRole("button", { name: "Forward" })
|
||||
|
||||
const back = page.getByRole("button", { name: "Back" })
|
||||
const forward = page.getByRole("button", { name: "Forward" })
|
||||
await expect(back).toBeVisible()
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
|
||||
await expect(back).toBeVisible()
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(forward).toBeVisible()
|
||||
await expect(forward).toBeEnabled()
|
||||
await forward.click()
|
||||
|
||||
await expect(forward).toBeVisible()
|
||||
await expect(forward).toBeEnabled()
|
||||
await forward.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
||||
}
|
||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openPalette } from "../actions"
|
||||
import { openPalette, clickListItem } from "../actions"
|
||||
|
||||
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -9,9 +9,7 @@ test("can open a file tab from the search palette", async ({ page, gotoSession }
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill("package.json")
|
||||
|
||||
const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||
await expect(fileItem).toBeVisible()
|
||||
await fileItem.click()
|
||||
await clickListItem(dialog, { keyStartsWith: "file:" })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openPalette } from "../actions"
|
||||
import { openPalette, clickListItem } from "../actions"
|
||||
|
||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -12,13 +12,7 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
||||
const input = dialog.getByRole("textbox").first()
|
||||
await input.fill(file)
|
||||
|
||||
const fileItem = dialog
|
||||
.locator(
|
||||
'[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
|
||||
)
|
||||
.first()
|
||||
await expect(fileItem).toBeVisible()
|
||||
await fileItem.click()
|
||||
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { clickListItem } from "../actions"
|
||||
|
||||
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
@@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
|
||||
|
||||
await input.fill(model)
|
||||
|
||||
const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
await clickListItem(dialog, { key })
|
||||
|
||||
await expect(dialog).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||
|
||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -14,7 +14,12 @@ test("dialog edit project updates name and startup script", async ({ page, gotoS
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click()
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible()
|
||||
|
||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
||||
await expect(editItem).toBeVisible()
|
||||
await editItem.click({ force: true })
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { createTestProject, seedProjects, cleanupTestProject, openSidebar } from "../actions"
|
||||
import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
|
||||
@@ -33,7 +33,7 @@ test("can close a project via project header more options menu", async ({ page,
|
||||
await page.setViewportSize({ width: 1400, height: 800 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherName = other.split("/").pop()
|
||||
const otherName = other.split("/").pop() ?? other
|
||||
const otherSlug = dirSlug(other)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
@@ -59,17 +59,10 @@ test("can close a project via project header more options menu", async ({ page,
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const close = page
|
||||
.locator(projectCloseMenuSelector(otherSlug))
|
||||
.or(page.getByRole("menuitem", { name: "Close" }))
|
||||
.or(
|
||||
page
|
||||
.locator('[data-component="dropdown-menu-content"] [data-slot="dropdown-menu-item"]')
|
||||
.filter({ hasText: "Close" }),
|
||||
)
|
||||
.first()
|
||||
await expect(close).toBeVisible({ timeout: 10_000 })
|
||||
await close.click({ force: true })
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { withSession } from "../actions"
|
||||
|
||||
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||
const title = `e2e smoke context ${Date.now()}`
|
||||
const created = await sdk.session.create({ title }).then((r) => r.data)
|
||||
|
||||
if (!created?.id) throw new Error("Session create did not return an id")
|
||||
const sessionID = created.id
|
||||
|
||||
try {
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await sdk.session.promptAsync({
|
||||
sessionID,
|
||||
sessionID: session.id,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
@@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
|
||||
return messages.length
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await gotoSession(sessionID)
|
||||
await gotoSession(session.id)
|
||||
|
||||
const contextButton = page
|
||||
.locator('[data-component="button"]')
|
||||
@@ -39,7 +36,5 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
|
||||
|
||||
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||
} finally {
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { sessionIDFromUrl, withSession } from "../actions"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
@@ -15,3 +15,21 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||
|
||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||
|
||||
export const titlebarRightSelector = "#opencode-titlebar-right"
|
||||
|
||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||
|
||||
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
||||
|
||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||
|
||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||
|
||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||
|
||||
export const listItemSelector = '[data-slot="list-item"]'
|
||||
|
||||
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
|
||||
|
||||
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
|
||||
|
||||
115
packages/app/e2e/session/session.spec.ts
Normal file
115
packages/app/e2e/session/session.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import {
|
||||
openSidebar,
|
||||
openSessionMoreMenu,
|
||||
clickMenuItem,
|
||||
confirmDialog,
|
||||
openSharePopover,
|
||||
withSession,
|
||||
} from "../actions"
|
||||
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
test("sidebar session can be renamed", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const originalTitle = `e2e rename test ${stamp}`
|
||||
const newTitle = `e2e renamed ${stamp}`
|
||||
|
||||
await withSession(sdk, originalTitle, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(sessionItemSelector(session.id)).locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(newTitle)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect(page.locator(sessionItemSelector(session.id)).locator("a").first()).toContainText(newTitle)
|
||||
})
|
||||
})
|
||||
|
||||
test("sidebar session can be archived", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e archive test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
|
||||
await expect(sessionEl).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test("sidebar session can be deleted", async ({ page, sdk, gotoSession }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e delete test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await openSidebar(page)
|
||||
|
||||
const sessionEl = page.locator(sessionItemSelector(session.id))
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
|
||||
await expect(sessionEl).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
const stamp = Date.now()
|
||||
const title = `e2e share test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const { rightSection, popoverBody } = await openSharePopover(page)
|
||||
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
|
||||
await expect(copyButton).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const sharedPopover = await openSharePopover(page)
|
||||
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||
await unpublish.click()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const unsharedPopover = await openSharePopover(page)
|
||||
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||
|
||||
test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
import { openSidebar, withSession } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
|
||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||
|
||||
@@ -58,7 +58,7 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
OPENCODE_DISABLE_SHARE: "true",
|
||||
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||
|
||||
@@ -90,7 +90,7 @@ const ModelList: Component<{
|
||||
|
||||
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
provider?: string
|
||||
children?: JSX.Element | ((open: boolean) => JSX.Element)
|
||||
children?: JSX.Element
|
||||
triggerAs?: T
|
||||
triggerProps?: ComponentProps<T>
|
||||
}) {
|
||||
@@ -182,13 +182,12 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
as={props.triggerAs ?? "div"}
|
||||
{...(props.triggerProps as any)}
|
||||
>
|
||||
{typeof props.children === "function" ? props.children(store.open) : props.children}
|
||||
{props.children}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
data-component="model-popover-content"
|
||||
ref={(el) => setStore("content", el)}
|
||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
onEscapeKeyDown={(event) => {
|
||||
setStore("dismiss", "escape")
|
||||
setStore("open", false)
|
||||
|
||||
@@ -32,9 +32,7 @@ import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { CycleLabel } from "@opencode-ai/ui/cycle-label"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
||||
@@ -44,7 +42,6 @@ import { Select } from "@opencode-ai/ui/select"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
@@ -1255,7 +1252,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
clearInput()
|
||||
client.session
|
||||
.shell({
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
@@ -1278,7 +1275,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
clearInput()
|
||||
client.session
|
||||
.command({
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
@@ -1434,13 +1431,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const optimisticParts = requestParts.map((part) => ({
|
||||
...part,
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})) as unknown as Part[]
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageID,
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent,
|
||||
@@ -1451,9 +1448,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session?.id || ""]
|
||||
const messages = draft.message[session.id]
|
||||
if (!messages) {
|
||||
draft.message[session?.id || ""] = [optimisticMessage]
|
||||
draft.message[session.id] = [optimisticMessage]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, optimisticMessage)
|
||||
@@ -1469,9 +1466,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
globalSync.child(sessionDirectory)[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session?.id || ""]
|
||||
const messages = draft.message[session.id]
|
||||
if (!messages) {
|
||||
draft.message[session?.id || ""] = [optimisticMessage]
|
||||
draft.message[session.id] = [optimisticMessage]
|
||||
} else {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
messages.splice(result.index, 0, optimisticMessage)
|
||||
@@ -1488,7 +1485,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session?.id || ""]
|
||||
const messages = draft.message[session.id]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
@@ -1501,7 +1498,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
globalSync.child(sessionDirectory)[1](
|
||||
produce((draft) => {
|
||||
const messages = draft.message[session?.id || ""]
|
||||
const messages = draft.message[session.id]
|
||||
if (messages) {
|
||||
const result = Binary.search(messages, messageID, (m) => m.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
@@ -1522,15 +1519,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
if (!worktree || worktree.status !== "pending") return true
|
||||
|
||||
if (sessionDirectory === projectDirectory && session?.id) {
|
||||
sync.set("session_status", session?.id, { type: "busy" })
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
const cleanup = () => {
|
||||
if (sessionDirectory === projectDirectory && session?.id) {
|
||||
sync.set("session_status", session?.id, { type: "idle" })
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
removeOptimisticMessage()
|
||||
for (const item of commentItems) {
|
||||
@@ -1547,7 +1544,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
restoreInput()
|
||||
}
|
||||
|
||||
pending.set(session?.id || "", { abort: controller, cleanup })
|
||||
pending.set(session.id, { abort: controller, cleanup })
|
||||
|
||||
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
|
||||
if (controller.signal.aborted) {
|
||||
@@ -1575,7 +1572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (timer.id === undefined) return
|
||||
clearTimeout(timer.id)
|
||||
})
|
||||
pending.delete(session?.id || "")
|
||||
pending.delete(session.id)
|
||||
if (controller.signal.aborted) return false
|
||||
if (result.status === "failed") throw new Error(result.message)
|
||||
return true
|
||||
@@ -1585,7 +1582,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const ok = await waitForWorktree()
|
||||
if (!ok) return
|
||||
await client.session.prompt({
|
||||
sessionID: session?.id || "",
|
||||
sessionID: session.id,
|
||||
agent,
|
||||
model,
|
||||
messageID,
|
||||
@@ -1595,9 +1592,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
void send().catch((err) => {
|
||||
pending.delete(session?.id || "")
|
||||
if (sessionDirectory === projectDirectory && session?.id) {
|
||||
sync.set("session_status", session?.id, { type: "idle" })
|
||||
pending.delete(session.id)
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "idle" })
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
@@ -1619,28 +1616,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
const currrentModelVariant = createMemo(() => {
|
||||
const modelVariant = local.model.variant.current() ?? ""
|
||||
return modelVariant === "xhigh"
|
||||
? "xHigh"
|
||||
: modelVariant.length > 0
|
||||
? modelVariant[0].toUpperCase() + modelVariant.slice(1)
|
||||
: "Default"
|
||||
})
|
||||
|
||||
const reasoningPercentage = createMemo(() => {
|
||||
const variants = local.model.variant.list()
|
||||
const current = local.model.variant.current()
|
||||
const totalEntries = variants.length + 1
|
||||
|
||||
if (totalEntries <= 2 || current === "Default") {
|
||||
return 0
|
||||
}
|
||||
|
||||
const currentIndex = current ? variants.indexOf(current) + 1 : 0
|
||||
return ((currentIndex + 1) / totalEntries) * 100
|
||||
}, [local.model.variant])
|
||||
|
||||
return (
|
||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
|
||||
<Show when={store.popover}>
|
||||
@@ -1693,7 +1668,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
|
||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">
|
||||
@{(item as { type: "agent"; name: string }).name}
|
||||
</span>
|
||||
@@ -1754,9 +1729,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
<Show when={store.dragging}>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||
<Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
|
||||
<Icon name="photo" class="size-8" />
|
||||
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1795,7 +1770,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
|
||||
<Show when={item.selection}>
|
||||
@@ -1812,7 +1787,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
|
||||
class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (item.commentID) comments.remove(item.path, item.commentID)
|
||||
@@ -1842,7 +1817,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
||||
<Icon name="folder" size="normal" class="size-6 text-text-base" />
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -1916,7 +1891,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="relative p-3 flex items-center justify-between">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<div class="flex items-center justify-start gap-0.5">
|
||||
<Switch>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<div class="flex items-center gap-2 px-2 h-6">
|
||||
@@ -1947,17 +1922,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
class="px-2"
|
||||
onClick={() => dialog.render(<DialogSelectModelUnpaid />, "select-model")}
|
||||
>
|
||||
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
<MorphChevron expanded={dialog.isActive("select-model")} />
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
@@ -1968,15 +1938,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
keybind={command.keybind("model.choose")}
|
||||
>
|
||||
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}>
|
||||
{(open) => (
|
||||
<>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
<MorphChevron expanded={open} class="text-text-weak" />
|
||||
</>
|
||||
)}
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
@@ -1989,13 +1955,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Button
|
||||
data-action="model-variant-cycle"
|
||||
variant="ghost"
|
||||
class="text-text-strong text-12-regular"
|
||||
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
|
||||
onClick={() => local.model.variant.cycle()}
|
||||
>
|
||||
<Show when={local.model.variant.list().length > 1}>
|
||||
<ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
|
||||
</Show>
|
||||
<CycleLabel value={currrentModelVariant()} />
|
||||
{local.model.variant.current() ?? language.t("common.default")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
@@ -2009,7 +1972,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
||||
classList={{
|
||||
"_hidden group-hover/prompt-input:flex items-center justify-center": true,
|
||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
||||
}}
|
||||
@@ -2031,7 +1994,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 absolute right-3 bottom-3">
|
||||
<div class="flex items-center gap-3 absolute right-3 bottom-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -2043,19 +2006,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
<div class="flex items-center gap-1.5 mr-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<SessionContextUsage />
|
||||
<Show when={store.mode === "normal"}>
|
||||
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
class="px-1"
|
||||
class="size-6"
|
||||
onClick={() => fileInputRef.click()}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
>
|
||||
<Icon name="photo" class="size-6 text-icon-base" />
|
||||
<Icon name="photo" class="size-4.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
@@ -2074,7 +2036,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Match when={true}>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.send")}</span>
|
||||
<Icon name="enter" size="normal" class="text-icon-base" />
|
||||
<Icon name="enter" size="small" class="text-icon-base" />
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -2085,7 +2047,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
disabled={!prompt.dirty() && !working()}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="h-6 w-5.5"
|
||||
class="h-6 w-4.5"
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -64,8 +64,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}
|
||||
|
||||
const circle = () => (
|
||||
<div class="text-icon-base">
|
||||
<ProgressCircle size={18} percentage={context()?.percentage ?? 0} />
|
||||
<div class="p-1">
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -101,7 +101,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-7 text-icon-base"
|
||||
class="size-6"
|
||||
onClick={openContext}
|
||||
aria-label={language.t("context.usage.view")}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,6 @@ import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
@@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => {
|
||||
const soundOptions = [...SOUND_OPTIONS]
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
|
||||
@@ -401,7 +395,7 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
@@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollFade
|
||||
direction="vertical"
|
||||
fadeStartSize={0}
|
||||
fadeEndSize={16}
|
||||
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
|
||||
>
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
@@ -435,6 +429,6 @@ export const SettingsKeybinds: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -345,7 +345,6 @@ pub fn run() {
|
||||
.decorations(false);
|
||||
|
||||
let window = window_builder.build().expect("Failed to create window");
|
||||
let _ = window.show();
|
||||
|
||||
#[cfg(windows)]
|
||||
let _ = window.create_overlay_titlebar();
|
||||
|
||||
@@ -50,25 +50,25 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.73",
|
||||
"@ai-sdk/anthropic": "2.0.57",
|
||||
"@ai-sdk/amazon-bedrock": "3.0.74",
|
||||
"@ai-sdk/anthropic": "2.0.58",
|
||||
"@ai-sdk/azure": "2.0.91",
|
||||
"@ai-sdk/cerebras": "1.0.34",
|
||||
"@ai-sdk/cerebras": "1.0.36",
|
||||
"@ai-sdk/cohere": "2.0.22",
|
||||
"@ai-sdk/deepinfra": "1.0.31",
|
||||
"@ai-sdk/gateway": "2.0.25",
|
||||
"@ai-sdk/deepinfra": "1.0.33",
|
||||
"@ai-sdk/gateway": "2.0.30",
|
||||
"@ai-sdk/google": "2.0.52",
|
||||
"@ai-sdk/google-vertex": "3.0.97",
|
||||
"@ai-sdk/google-vertex": "3.0.98",
|
||||
"@ai-sdk/groq": "2.0.34",
|
||||
"@ai-sdk/mistral": "2.0.27",
|
||||
"@ai-sdk/openai": "2.0.89",
|
||||
"@ai-sdk/openai-compatible": "1.0.30",
|
||||
"@ai-sdk/openai-compatible": "1.0.32",
|
||||
"@ai-sdk/perplexity": "2.0.23",
|
||||
"@ai-sdk/provider": "2.0.1",
|
||||
"@ai-sdk/provider-utils": "3.0.20",
|
||||
"@ai-sdk/togetherai": "1.0.31",
|
||||
"@ai-sdk/vercel": "1.0.31",
|
||||
"@ai-sdk/xai": "2.0.51",
|
||||
"@ai-sdk/togetherai": "1.0.34",
|
||||
"@ai-sdk/vercel": "1.0.33",
|
||||
"@ai-sdk/xai": "2.0.56",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@gitlab/gitlab-ai-provider": "3.3.1",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "1.5.2",
|
||||
"@openrouter/ai-sdk-provider": "1.5.4",
|
||||
"@opentui/core": "0.1.75",
|
||||
"@opentui/solid": "0.1.75",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
|
||||
@@ -14,11 +14,11 @@ process.chdir(dir)
|
||||
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
|
||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
// Fetch and generate models.dev snapshot
|
||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`https://models.dev/api.json`).then((x) => x.text())
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
|
||||
@@ -20,6 +20,7 @@ export const AcpCommand = cmd({
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
|
||||
@@ -345,8 +345,9 @@ export function Autocomplete(props: {
|
||||
const results: AutocompleteOption[] = [...command.slashes()]
|
||||
|
||||
for (const serverCommand of sync.data.command) {
|
||||
const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : ""
|
||||
results.push({
|
||||
display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
|
||||
display: "/" + serverCommand.name + label,
|
||||
description: serverCommand.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + serverCommand.name + " "
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
|
||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||
import PROMPT_REVIEW from "./template/review.txt"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
|
||||
export namespace Command {
|
||||
export const Event = {
|
||||
@@ -26,7 +27,7 @@ export namespace Command {
|
||||
description: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
mcp: z.boolean().optional(),
|
||||
source: z.enum(["command", "mcp", "skill"]).optional(),
|
||||
// workaround for zod not supporting async functions natively so we use getters
|
||||
// https://zod.dev/v4/changelog?id=zfunction
|
||||
template: z.promise(z.string()).or(z.string()),
|
||||
@@ -94,7 +95,7 @@ export namespace Command {
|
||||
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
|
||||
result[name] = {
|
||||
name,
|
||||
mcp: true,
|
||||
source: "mcp",
|
||||
description: prompt.description,
|
||||
get template() {
|
||||
// since a getter can't be async we need to manually return a promise here
|
||||
@@ -118,6 +119,21 @@ export namespace Command {
|
||||
}
|
||||
}
|
||||
|
||||
// Add skills as invokable commands
|
||||
for (const skill of await Skill.all()) {
|
||||
// Skip if a command with this name already exists
|
||||
if (result[skill.name]) continue
|
||||
result[skill.name] = {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
source: "skill",
|
||||
get template() {
|
||||
return skill.content
|
||||
},
|
||||
hints: [],
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
4
packages/opencode/src/env/index.ts
vendored
4
packages/opencode/src/env/index.ts
vendored
@@ -2,7 +2,9 @@ import { Instance } from "../project/instance"
|
||||
|
||||
export namespace Env {
|
||||
const state = Instance.state(() => {
|
||||
return process.env as Record<string, string | undefined>
|
||||
// Create a shallow copy to isolate environment per instance
|
||||
// Prevents parallel tests from interfering with each other's env vars
|
||||
return { ...process.env } as Record<string, string | undefined>
|
||||
})
|
||||
|
||||
export function get(key: string) {
|
||||
|
||||
@@ -214,8 +214,8 @@ export namespace Ripgrep {
|
||||
input.signal?.throwIfAborted()
|
||||
|
||||
const args = [await filepath(), "--files", "--glob=!.git/*"]
|
||||
if (input.follow !== false) args.push("--follow")
|
||||
if (input.hidden !== false) args.push("--hidden")
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.hidden) args.push("--hidden")
|
||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
@@ -381,7 +381,7 @@ export namespace Ripgrep {
|
||||
follow?: boolean
|
||||
}) {
|
||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
|
||||
if (input.follow !== false) args.push("--follow")
|
||||
if (input.follow) args.push("--follow")
|
||||
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
|
||||
@@ -25,7 +25,7 @@ export namespace Flag {
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
|
||||
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
|
||||
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
|
||||
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
|
||||
export declare const OPENCODE_CLIENT: string
|
||||
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
|
||||
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
|
||||
|
||||
@@ -47,6 +47,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
|
||||
function number(key: string) {
|
||||
const value = process.env[key]
|
||||
@@ -77,3 +78,14 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
// Dynamic getter for OPENCODE_CLIENT
|
||||
// This must be evaluated at access time, not module load time,
|
||||
// because some commands override the client at runtime
|
||||
Object.defineProperty(Flag, "OPENCODE_CLIENT", {
|
||||
get() {
|
||||
return process.env["OPENCODE_CLIENT"] ?? "cli"
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ export namespace ModelsDev {
|
||||
}
|
||||
|
||||
export const Data = lazy(async () => {
|
||||
const file = Bun.file(filepath)
|
||||
const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath)
|
||||
const result = await file.json().catch(() => {})
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
|
||||
@@ -24,7 +24,7 @@ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
|
||||
import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
|
||||
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
|
||||
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
|
||||
import { createXai } from "@ai-sdk/xai"
|
||||
import { createMistral } from "@ai-sdk/mistral"
|
||||
import { createGroq } from "@ai-sdk/groq"
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
type LanguageModelV2Prompt,
|
||||
type SharedV2ProviderMetadata,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import type { OpenAICompatibleChatPrompt } from "./openai-compatible-api-types"
|
||||
import { convertToBase64 } from "@ai-sdk/provider-utils"
|
||||
|
||||
function getOpenAIMetadata(message: { providerOptions?: SharedV2ProviderMetadata }) {
|
||||
return message?.providerOptions?.copilot ?? {}
|
||||
}
|
||||
|
||||
export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Prompt): OpenAICompatibleChatPrompt {
|
||||
const messages: OpenAICompatibleChatPrompt = []
|
||||
for (const { role, content, ...message } of prompt) {
|
||||
const metadata = getOpenAIMetadata({ ...message })
|
||||
switch (role) {
|
||||
case "system": {
|
||||
messages.push({
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: content,
|
||||
},
|
||||
],
|
||||
...metadata,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "user": {
|
||||
if (content.length === 1 && content[0].type === "text") {
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: content[0].text,
|
||||
...getOpenAIMetadata(content[0]),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: content.map((part) => {
|
||||
const partMetadata = getOpenAIMetadata(part)
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
return { type: "text", text: part.text, ...partMetadata }
|
||||
}
|
||||
case "file": {
|
||||
if (part.mediaType.startsWith("image/")) {
|
||||
const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType
|
||||
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url:
|
||||
part.data instanceof URL
|
||||
? part.data.toString()
|
||||
: `data:${mediaType};base64,${convertToBase64(part.data)}`,
|
||||
},
|
||||
...partMetadata,
|
||||
}
|
||||
} else {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `file part media type ${part.mediaType}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
...metadata,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case "assistant": {
|
||||
let text = ""
|
||||
let reasoningText: string | undefined
|
||||
let reasoningOpaque: string | undefined
|
||||
const toolCalls: Array<{
|
||||
id: string
|
||||
type: "function"
|
||||
function: { name: string; arguments: string }
|
||||
}> = []
|
||||
|
||||
for (const part of content) {
|
||||
const partMetadata = getOpenAIMetadata(part)
|
||||
// Check for reasoningOpaque on any part (may be attached to text/tool-call)
|
||||
const partOpaque = (part.providerOptions as { copilot?: { reasoningOpaque?: string } })?.copilot
|
||||
?.reasoningOpaque
|
||||
if (partOpaque && !reasoningOpaque) {
|
||||
reasoningOpaque = partOpaque
|
||||
}
|
||||
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
text += part.text
|
||||
break
|
||||
}
|
||||
case "reasoning": {
|
||||
reasoningText = part.text
|
||||
break
|
||||
}
|
||||
case "tool-call": {
|
||||
toolCalls.push({
|
||||
id: part.toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.toolName,
|
||||
arguments: JSON.stringify(part.input),
|
||||
},
|
||||
...partMetadata,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: text || null,
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
reasoning_text: reasoningText,
|
||||
reasoning_opaque: reasoningOpaque,
|
||||
...metadata,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case "tool": {
|
||||
for (const toolResponse of content) {
|
||||
const output = toolResponse.output
|
||||
|
||||
let contentValue: string
|
||||
switch (output.type) {
|
||||
case "text":
|
||||
case "error-text":
|
||||
contentValue = output.value
|
||||
break
|
||||
case "content":
|
||||
case "json":
|
||||
case "error-json":
|
||||
contentValue = JSON.stringify(output.value)
|
||||
break
|
||||
}
|
||||
|
||||
const toolResponseMetadata = getOpenAIMetadata(toolResponse)
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: toolResponse.toolCallId,
|
||||
content: contentValue,
|
||||
...toolResponseMetadata,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
const _exhaustiveCheck: never = role
|
||||
throw new Error(`Unsupported role: ${_exhaustiveCheck}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function getResponseMetadata({
|
||||
id,
|
||||
model,
|
||||
created,
|
||||
}: {
|
||||
id?: string | undefined | null
|
||||
created?: number | undefined | null
|
||||
model?: string | undefined | null
|
||||
}) {
|
||||
return {
|
||||
id: id ?? undefined,
|
||||
modelId: model ?? undefined,
|
||||
timestamp: created != null ? new Date(created * 1000) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { LanguageModelV2FinishReason } from "@ai-sdk/provider"
|
||||
|
||||
export function mapOpenAICompatibleFinishReason(finishReason: string | null | undefined): LanguageModelV2FinishReason {
|
||||
switch (finishReason) {
|
||||
case "stop":
|
||||
return "stop"
|
||||
case "length":
|
||||
return "length"
|
||||
case "content_filter":
|
||||
return "content-filter"
|
||||
case "function_call":
|
||||
case "tool_calls":
|
||||
return "tool-calls"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { JSONValue } from "@ai-sdk/provider"
|
||||
|
||||
export type OpenAICompatibleChatPrompt = Array<OpenAICompatibleMessage>
|
||||
|
||||
export type OpenAICompatibleMessage =
|
||||
| OpenAICompatibleSystemMessage
|
||||
| OpenAICompatibleUserMessage
|
||||
| OpenAICompatibleAssistantMessage
|
||||
| OpenAICompatibleToolMessage
|
||||
|
||||
// Allow for arbitrary additional properties for general purpose
|
||||
// provider-metadata-specific extensibility.
|
||||
type JsonRecord<T = never> = Record<string, JSONValue | JSONValue[] | T | T[] | undefined>
|
||||
|
||||
export interface OpenAICompatibleSystemMessage extends JsonRecord<OpenAICompatibleSystemContentPart> {
|
||||
role: "system"
|
||||
content: string | Array<OpenAICompatibleSystemContentPart>
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleSystemContentPart extends JsonRecord {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleUserMessage extends JsonRecord<OpenAICompatibleContentPart> {
|
||||
role: "user"
|
||||
content: string | Array<OpenAICompatibleContentPart>
|
||||
}
|
||||
|
||||
export type OpenAICompatibleContentPart = OpenAICompatibleContentPartText | OpenAICompatibleContentPartImage
|
||||
|
||||
export interface OpenAICompatibleContentPartImage extends JsonRecord {
|
||||
type: "image_url"
|
||||
image_url: { url: string }
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleContentPartText extends JsonRecord {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleAssistantMessage extends JsonRecord<OpenAICompatibleMessageToolCall> {
|
||||
role: "assistant"
|
||||
content?: string | null
|
||||
tool_calls?: Array<OpenAICompatibleMessageToolCall>
|
||||
// Copilot-specific reasoning fields
|
||||
reasoning_text?: string
|
||||
reasoning_opaque?: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleMessageToolCall extends JsonRecord {
|
||||
type: "function"
|
||||
id: string
|
||||
function: {
|
||||
arguments: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleToolMessage extends JsonRecord {
|
||||
role: "tool"
|
||||
content: string
|
||||
tool_call_id: string
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
import {
|
||||
APICallError,
|
||||
InvalidResponseDataError,
|
||||
type LanguageModelV2,
|
||||
type LanguageModelV2CallWarning,
|
||||
type LanguageModelV2Content,
|
||||
type LanguageModelV2FinishReason,
|
||||
type LanguageModelV2StreamPart,
|
||||
type SharedV2ProviderMetadata,
|
||||
} from "@ai-sdk/provider"
|
||||
import {
|
||||
combineHeaders,
|
||||
createEventSourceResponseHandler,
|
||||
createJsonErrorResponseHandler,
|
||||
createJsonResponseHandler,
|
||||
type FetchFunction,
|
||||
generateId,
|
||||
isParsableJson,
|
||||
parseProviderOptions,
|
||||
type ParseResult,
|
||||
postJsonToApi,
|
||||
type ResponseHandler,
|
||||
} from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
import { convertToOpenAICompatibleChatMessages } from "./convert-to-openai-compatible-chat-messages"
|
||||
import { getResponseMetadata } from "./get-response-metadata"
|
||||
import { mapOpenAICompatibleFinishReason } from "./map-openai-compatible-finish-reason"
|
||||
import { type OpenAICompatibleChatModelId, openaiCompatibleProviderOptions } from "./openai-compatible-chat-options"
|
||||
import { defaultOpenAICompatibleErrorStructure, type ProviderErrorStructure } from "../openai-compatible-error"
|
||||
import type { MetadataExtractor } from "./openai-compatible-metadata-extractor"
|
||||
import { prepareTools } from "./openai-compatible-prepare-tools"
|
||||
|
||||
export type OpenAICompatibleChatConfig = {
|
||||
provider: string
|
||||
headers: () => Record<string, string | undefined>
|
||||
url: (options: { modelId: string; path: string }) => string
|
||||
fetch?: FetchFunction
|
||||
includeUsage?: boolean
|
||||
errorStructure?: ProviderErrorStructure<any>
|
||||
metadataExtractor?: MetadataExtractor
|
||||
|
||||
/**
|
||||
* Whether the model supports structured outputs.
|
||||
*/
|
||||
supportsStructuredOutputs?: boolean
|
||||
|
||||
/**
|
||||
* The supported URLs for the model.
|
||||
*/
|
||||
supportedUrls?: () => LanguageModelV2["supportedUrls"]
|
||||
}
|
||||
|
||||
export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
|
||||
readonly specificationVersion = "v2"
|
||||
|
||||
readonly supportsStructuredOutputs: boolean
|
||||
|
||||
readonly modelId: OpenAICompatibleChatModelId
|
||||
private readonly config: OpenAICompatibleChatConfig
|
||||
private readonly failedResponseHandler: ResponseHandler<APICallError>
|
||||
private readonly chunkSchema // type inferred via constructor
|
||||
|
||||
constructor(modelId: OpenAICompatibleChatModelId, config: OpenAICompatibleChatConfig) {
|
||||
this.modelId = modelId
|
||||
this.config = config
|
||||
|
||||
// initialize error handling:
|
||||
const errorStructure = config.errorStructure ?? defaultOpenAICompatibleErrorStructure
|
||||
this.chunkSchema = createOpenAICompatibleChatChunkSchema(errorStructure.errorSchema)
|
||||
this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure)
|
||||
|
||||
this.supportsStructuredOutputs = config.supportsStructuredOutputs ?? false
|
||||
}
|
||||
|
||||
get provider(): string {
|
||||
return this.config.provider
|
||||
}
|
||||
|
||||
private get providerOptionsName(): string {
|
||||
return this.config.provider.split(".")[0].trim()
|
||||
}
|
||||
|
||||
get supportedUrls() {
|
||||
return this.config.supportedUrls?.() ?? {}
|
||||
}
|
||||
|
||||
private async getArgs({
|
||||
prompt,
|
||||
maxOutputTokens,
|
||||
temperature,
|
||||
topP,
|
||||
topK,
|
||||
frequencyPenalty,
|
||||
presencePenalty,
|
||||
providerOptions,
|
||||
stopSequences,
|
||||
responseFormat,
|
||||
seed,
|
||||
toolChoice,
|
||||
tools,
|
||||
}: Parameters<LanguageModelV2["doGenerate"]>[0]) {
|
||||
const warnings: LanguageModelV2CallWarning[] = []
|
||||
|
||||
// Parse provider options
|
||||
const compatibleOptions = Object.assign(
|
||||
(await parseProviderOptions({
|
||||
provider: "copilot",
|
||||
providerOptions,
|
||||
schema: openaiCompatibleProviderOptions,
|
||||
})) ?? {},
|
||||
(await parseProviderOptions({
|
||||
provider: this.providerOptionsName,
|
||||
providerOptions,
|
||||
schema: openaiCompatibleProviderOptions,
|
||||
})) ?? {},
|
||||
)
|
||||
|
||||
if (topK != null) {
|
||||
warnings.push({ type: "unsupported-setting", setting: "topK" })
|
||||
}
|
||||
|
||||
if (responseFormat?.type === "json" && responseFormat.schema != null && !this.supportsStructuredOutputs) {
|
||||
warnings.push({
|
||||
type: "unsupported-setting",
|
||||
setting: "responseFormat",
|
||||
details: "JSON response format schema is only supported with structuredOutputs",
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
tools: openaiTools,
|
||||
toolChoice: openaiToolChoice,
|
||||
toolWarnings,
|
||||
} = prepareTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
})
|
||||
|
||||
return {
|
||||
args: {
|
||||
// model id:
|
||||
model: this.modelId,
|
||||
|
||||
// model specific settings:
|
||||
user: compatibleOptions.user,
|
||||
|
||||
// standardized settings:
|
||||
max_tokens: maxOutputTokens,
|
||||
temperature,
|
||||
top_p: topP,
|
||||
frequency_penalty: frequencyPenalty,
|
||||
presence_penalty: presencePenalty,
|
||||
response_format:
|
||||
responseFormat?.type === "json"
|
||||
? this.supportsStructuredOutputs === true && responseFormat.schema != null
|
||||
? {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
schema: responseFormat.schema,
|
||||
name: responseFormat.name ?? "response",
|
||||
description: responseFormat.description,
|
||||
},
|
||||
}
|
||||
: { type: "json_object" }
|
||||
: undefined,
|
||||
|
||||
stop: stopSequences,
|
||||
seed,
|
||||
...Object.fromEntries(
|
||||
Object.entries(providerOptions?.[this.providerOptionsName] ?? {}).filter(
|
||||
([key]) => !Object.keys(openaiCompatibleProviderOptions.shape).includes(key),
|
||||
),
|
||||
),
|
||||
|
||||
reasoning_effort: compatibleOptions.reasoningEffort,
|
||||
verbosity: compatibleOptions.textVerbosity,
|
||||
|
||||
// messages:
|
||||
messages: convertToOpenAICompatibleChatMessages(prompt),
|
||||
|
||||
// tools:
|
||||
tools: openaiTools,
|
||||
tool_choice: openaiToolChoice,
|
||||
|
||||
// thinking_budget
|
||||
thinking_budget: compatibleOptions.thinking_budget,
|
||||
},
|
||||
warnings: [...warnings, ...toolWarnings],
|
||||
}
|
||||
}
|
||||
|
||||
async doGenerate(
|
||||
options: Parameters<LanguageModelV2["doGenerate"]>[0],
|
||||
): Promise<Awaited<ReturnType<LanguageModelV2["doGenerate"]>>> {
|
||||
const { args, warnings } = await this.getArgs({ ...options })
|
||||
|
||||
const body = JSON.stringify(args)
|
||||
|
||||
const {
|
||||
responseHeaders,
|
||||
value: responseBody,
|
||||
rawValue: rawResponse,
|
||||
} = await postJsonToApi({
|
||||
url: this.config.url({
|
||||
path: "/chat/completions",
|
||||
modelId: this.modelId,
|
||||
}),
|
||||
headers: combineHeaders(this.config.headers(), options.headers),
|
||||
body: args,
|
||||
failedResponseHandler: this.failedResponseHandler,
|
||||
successfulResponseHandler: createJsonResponseHandler(OpenAICompatibleChatResponseSchema),
|
||||
abortSignal: options.abortSignal,
|
||||
fetch: this.config.fetch,
|
||||
})
|
||||
|
||||
const choice = responseBody.choices[0]
|
||||
const content: Array<LanguageModelV2Content> = []
|
||||
|
||||
// text content:
|
||||
const text = choice.message.content
|
||||
if (text != null && text.length > 0) {
|
||||
content.push({ type: "text", text })
|
||||
}
|
||||
|
||||
// reasoning content (Copilot uses reasoning_text):
|
||||
const reasoning = choice.message.reasoning_text
|
||||
if (reasoning != null && reasoning.length > 0) {
|
||||
content.push({
|
||||
type: "reasoning",
|
||||
text: reasoning,
|
||||
// Include reasoning_opaque for Copilot multi-turn reasoning
|
||||
providerMetadata: choice.message.reasoning_opaque
|
||||
? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// tool calls:
|
||||
if (choice.message.tool_calls != null) {
|
||||
for (const toolCall of choice.message.tool_calls) {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.id ?? generateId(),
|
||||
toolName: toolCall.function.name,
|
||||
input: toolCall.function.arguments!,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// provider metadata:
|
||||
const providerMetadata: SharedV2ProviderMetadata = {
|
||||
[this.providerOptionsName]: {},
|
||||
...(await this.config.metadataExtractor?.extractMetadata?.({
|
||||
parsedBody: rawResponse,
|
||||
})),
|
||||
}
|
||||
const completionTokenDetails = responseBody.usage?.completion_tokens_details
|
||||
if (completionTokenDetails?.accepted_prediction_tokens != null) {
|
||||
providerMetadata[this.providerOptionsName].acceptedPredictionTokens =
|
||||
completionTokenDetails?.accepted_prediction_tokens
|
||||
}
|
||||
if (completionTokenDetails?.rejected_prediction_tokens != null) {
|
||||
providerMetadata[this.providerOptionsName].rejectedPredictionTokens =
|
||||
completionTokenDetails?.rejected_prediction_tokens
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
finishReason: mapOpenAICompatibleFinishReason(choice.finish_reason),
|
||||
usage: {
|
||||
inputTokens: responseBody.usage?.prompt_tokens ?? undefined,
|
||||
outputTokens: responseBody.usage?.completion_tokens ?? undefined,
|
||||
totalTokens: responseBody.usage?.total_tokens ?? undefined,
|
||||
reasoningTokens: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
|
||||
cachedInputTokens: responseBody.usage?.prompt_tokens_details?.cached_tokens ?? undefined,
|
||||
},
|
||||
providerMetadata,
|
||||
request: { body },
|
||||
response: {
|
||||
...getResponseMetadata(responseBody),
|
||||
headers: responseHeaders,
|
||||
body: rawResponse,
|
||||
},
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
async doStream(
|
||||
options: Parameters<LanguageModelV2["doStream"]>[0],
|
||||
): Promise<Awaited<ReturnType<LanguageModelV2["doStream"]>>> {
|
||||
const { args, warnings } = await this.getArgs({ ...options })
|
||||
|
||||
const body = {
|
||||
...args,
|
||||
stream: true,
|
||||
|
||||
// only include stream_options when in strict compatibility mode:
|
||||
stream_options: this.config.includeUsage ? { include_usage: true } : undefined,
|
||||
}
|
||||
|
||||
const metadataExtractor = this.config.metadataExtractor?.createStreamExtractor()
|
||||
|
||||
const { responseHeaders, value: response } = await postJsonToApi({
|
||||
url: this.config.url({
|
||||
path: "/chat/completions",
|
||||
modelId: this.modelId,
|
||||
}),
|
||||
headers: combineHeaders(this.config.headers(), options.headers),
|
||||
body,
|
||||
failedResponseHandler: this.failedResponseHandler,
|
||||
successfulResponseHandler: createEventSourceResponseHandler(this.chunkSchema),
|
||||
abortSignal: options.abortSignal,
|
||||
fetch: this.config.fetch,
|
||||
})
|
||||
|
||||
const toolCalls: Array<{
|
||||
id: string
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
hasFinished: boolean
|
||||
}> = []
|
||||
|
||||
let finishReason: LanguageModelV2FinishReason = "unknown"
|
||||
const usage: {
|
||||
completionTokens: number | undefined
|
||||
completionTokensDetails: {
|
||||
reasoningTokens: number | undefined
|
||||
acceptedPredictionTokens: number | undefined
|
||||
rejectedPredictionTokens: number | undefined
|
||||
}
|
||||
promptTokens: number | undefined
|
||||
promptTokensDetails: {
|
||||
cachedTokens: number | undefined
|
||||
}
|
||||
totalTokens: number | undefined
|
||||
} = {
|
||||
completionTokens: undefined,
|
||||
completionTokensDetails: {
|
||||
reasoningTokens: undefined,
|
||||
acceptedPredictionTokens: undefined,
|
||||
rejectedPredictionTokens: undefined,
|
||||
},
|
||||
promptTokens: undefined,
|
||||
promptTokensDetails: {
|
||||
cachedTokens: undefined,
|
||||
},
|
||||
totalTokens: undefined,
|
||||
}
|
||||
let isFirstChunk = true
|
||||
const providerOptionsName = this.providerOptionsName
|
||||
let isActiveReasoning = false
|
||||
let isActiveText = false
|
||||
let reasoningOpaque: string | undefined
|
||||
|
||||
return {
|
||||
stream: response.pipeThrough(
|
||||
new TransformStream<ParseResult<z.infer<typeof this.chunkSchema>>, LanguageModelV2StreamPart>({
|
||||
start(controller) {
|
||||
controller.enqueue({ type: "stream-start", warnings })
|
||||
},
|
||||
|
||||
// TODO we lost type safety on Chunk, most likely due to the error schema. MUST FIX
|
||||
transform(chunk, controller) {
|
||||
// Emit raw chunk if requested (before anything else)
|
||||
if (options.includeRawChunks) {
|
||||
controller.enqueue({ type: "raw", rawValue: chunk.rawValue })
|
||||
}
|
||||
|
||||
// handle failed chunk parsing / validation:
|
||||
if (!chunk.success) {
|
||||
finishReason = "error"
|
||||
controller.enqueue({ type: "error", error: chunk.error })
|
||||
return
|
||||
}
|
||||
const value = chunk.value
|
||||
|
||||
metadataExtractor?.processChunk(chunk.rawValue)
|
||||
|
||||
// handle error chunks:
|
||||
if ("error" in value) {
|
||||
finishReason = "error"
|
||||
controller.enqueue({ type: "error", error: value.error.message })
|
||||
return
|
||||
}
|
||||
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
|
||||
controller.enqueue({
|
||||
type: "response-metadata",
|
||||
...getResponseMetadata(value),
|
||||
})
|
||||
}
|
||||
|
||||
if (value.usage != null) {
|
||||
const {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
prompt_tokens_details,
|
||||
completion_tokens_details,
|
||||
} = value.usage
|
||||
|
||||
usage.promptTokens = prompt_tokens ?? undefined
|
||||
usage.completionTokens = completion_tokens ?? undefined
|
||||
usage.totalTokens = total_tokens ?? undefined
|
||||
if (completion_tokens_details?.reasoning_tokens != null) {
|
||||
usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
|
||||
}
|
||||
if (completion_tokens_details?.accepted_prediction_tokens != null) {
|
||||
usage.completionTokensDetails.acceptedPredictionTokens =
|
||||
completion_tokens_details?.accepted_prediction_tokens
|
||||
}
|
||||
if (completion_tokens_details?.rejected_prediction_tokens != null) {
|
||||
usage.completionTokensDetails.rejectedPredictionTokens =
|
||||
completion_tokens_details?.rejected_prediction_tokens
|
||||
}
|
||||
if (prompt_tokens_details?.cached_tokens != null) {
|
||||
usage.promptTokensDetails.cachedTokens = prompt_tokens_details?.cached_tokens
|
||||
}
|
||||
}
|
||||
|
||||
const choice = value.choices[0]
|
||||
|
||||
if (choice?.finish_reason != null) {
|
||||
finishReason = mapOpenAICompatibleFinishReason(choice.finish_reason)
|
||||
}
|
||||
|
||||
if (choice?.delta == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const delta = choice.delta
|
||||
|
||||
// Capture reasoning_opaque for Copilot multi-turn reasoning
|
||||
if (delta.reasoning_opaque) {
|
||||
if (reasoningOpaque != null) {
|
||||
throw new InvalidResponseDataError({
|
||||
data: delta,
|
||||
message:
|
||||
"Multiple reasoning_opaque values received in a single response. Only one thinking part per response is supported.",
|
||||
})
|
||||
}
|
||||
reasoningOpaque = delta.reasoning_opaque
|
||||
}
|
||||
|
||||
// enqueue reasoning before text deltas (Copilot uses reasoning_text):
|
||||
const reasoningContent = delta.reasoning_text
|
||||
if (reasoningContent) {
|
||||
if (!isActiveReasoning) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-start",
|
||||
id: "reasoning-0",
|
||||
})
|
||||
isActiveReasoning = true
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: "reasoning-delta",
|
||||
id: "reasoning-0",
|
||||
delta: reasoningContent,
|
||||
})
|
||||
}
|
||||
|
||||
if (delta.content) {
|
||||
// If reasoning was active and we're starting text, end reasoning first
|
||||
// This handles the case where reasoning_opaque and content come in the same chunk
|
||||
if (isActiveReasoning && !isActiveText) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
isActiveReasoning = false
|
||||
}
|
||||
|
||||
if (!isActiveText) {
|
||||
controller.enqueue({ type: "text-start", id: "txt-0" })
|
||||
isActiveText = true
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: "text-delta",
|
||||
id: "txt-0",
|
||||
delta: delta.content,
|
||||
})
|
||||
}
|
||||
|
||||
if (delta.tool_calls != null) {
|
||||
// If reasoning was active and we're starting tool calls, end reasoning first
|
||||
// This handles the case where reasoning goes directly to tool calls with no content
|
||||
if (isActiveReasoning) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
isActiveReasoning = false
|
||||
}
|
||||
for (const toolCallDelta of delta.tool_calls) {
|
||||
const index = toolCallDelta.index
|
||||
|
||||
if (toolCalls[index] == null) {
|
||||
if (toolCallDelta.id == null) {
|
||||
throw new InvalidResponseDataError({
|
||||
data: toolCallDelta,
|
||||
message: `Expected 'id' to be a string.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (toolCallDelta.function?.name == null) {
|
||||
throw new InvalidResponseDataError({
|
||||
data: toolCallDelta,
|
||||
message: `Expected 'function.name' to be a string.`,
|
||||
})
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
id: toolCallDelta.id,
|
||||
toolName: toolCallDelta.function.name,
|
||||
})
|
||||
|
||||
toolCalls[index] = {
|
||||
id: toolCallDelta.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolCallDelta.function.name,
|
||||
arguments: toolCallDelta.function.arguments ?? "",
|
||||
},
|
||||
hasFinished: false,
|
||||
}
|
||||
|
||||
const toolCall = toolCalls[index]
|
||||
|
||||
if (toolCall.function?.name != null && toolCall.function?.arguments != null) {
|
||||
// send delta if the argument text has already started:
|
||||
if (toolCall.function.arguments.length > 0) {
|
||||
controller.enqueue({
|
||||
type: "tool-input-delta",
|
||||
id: toolCall.id,
|
||||
delta: toolCall.function.arguments,
|
||||
})
|
||||
}
|
||||
|
||||
// check if tool call is complete
|
||||
// (some providers send the full tool call in one chunk):
|
||||
if (isParsableJson(toolCall.function.arguments)) {
|
||||
controller.enqueue({
|
||||
type: "tool-input-end",
|
||||
id: toolCall.id,
|
||||
})
|
||||
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.id ?? generateId(),
|
||||
toolName: toolCall.function.name,
|
||||
input: toolCall.function.arguments,
|
||||
})
|
||||
toolCall.hasFinished = true
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// existing tool call, merge if not finished
|
||||
const toolCall = toolCalls[index]
|
||||
|
||||
if (toolCall.hasFinished) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (toolCallDelta.function?.arguments != null) {
|
||||
toolCall.function!.arguments += toolCallDelta.function?.arguments ?? ""
|
||||
}
|
||||
|
||||
// send delta
|
||||
controller.enqueue({
|
||||
type: "tool-input-delta",
|
||||
id: toolCall.id,
|
||||
delta: toolCallDelta.function.arguments ?? "",
|
||||
})
|
||||
|
||||
// check if tool call is complete
|
||||
if (
|
||||
toolCall.function?.name != null &&
|
||||
toolCall.function?.arguments != null &&
|
||||
isParsableJson(toolCall.function.arguments)
|
||||
) {
|
||||
controller.enqueue({
|
||||
type: "tool-input-end",
|
||||
id: toolCall.id,
|
||||
})
|
||||
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.id ?? generateId(),
|
||||
toolName: toolCall.function.name,
|
||||
input: toolCall.function.arguments,
|
||||
})
|
||||
toolCall.hasFinished = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
if (isActiveReasoning) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
// Include reasoning_opaque for Copilot multi-turn reasoning
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (isActiveText) {
|
||||
controller.enqueue({ type: "text-end", id: "txt-0" })
|
||||
}
|
||||
|
||||
// go through all tool calls and send the ones that are not finished
|
||||
for (const toolCall of toolCalls.filter((toolCall) => !toolCall.hasFinished)) {
|
||||
controller.enqueue({
|
||||
type: "tool-input-end",
|
||||
id: toolCall.id,
|
||||
})
|
||||
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.id ?? generateId(),
|
||||
toolName: toolCall.function.name,
|
||||
input: toolCall.function.arguments,
|
||||
})
|
||||
}
|
||||
|
||||
const providerMetadata: SharedV2ProviderMetadata = {
|
||||
[providerOptionsName]: {},
|
||||
// Include reasoning_opaque for Copilot multi-turn reasoning
|
||||
...(reasoningOpaque ? { copilot: { reasoningOpaque } } : {}),
|
||||
...metadataExtractor?.buildMetadata(),
|
||||
}
|
||||
if (usage.completionTokensDetails.acceptedPredictionTokens != null) {
|
||||
providerMetadata[providerOptionsName].acceptedPredictionTokens =
|
||||
usage.completionTokensDetails.acceptedPredictionTokens
|
||||
}
|
||||
if (usage.completionTokensDetails.rejectedPredictionTokens != null) {
|
||||
providerMetadata[providerOptionsName].rejectedPredictionTokens =
|
||||
usage.completionTokensDetails.rejectedPredictionTokens
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: "finish",
|
||||
finishReason,
|
||||
usage: {
|
||||
inputTokens: usage.promptTokens ?? undefined,
|
||||
outputTokens: usage.completionTokens ?? undefined,
|
||||
totalTokens: usage.totalTokens ?? undefined,
|
||||
reasoningTokens: usage.completionTokensDetails.reasoningTokens ?? undefined,
|
||||
cachedInputTokens: usage.promptTokensDetails.cachedTokens ?? undefined,
|
||||
},
|
||||
providerMetadata,
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
request: { body },
|
||||
response: { headers: responseHeaders },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openaiCompatibleTokenUsageSchema = z
|
||||
.object({
|
||||
prompt_tokens: z.number().nullish(),
|
||||
completion_tokens: z.number().nullish(),
|
||||
total_tokens: z.number().nullish(),
|
||||
prompt_tokens_details: z
|
||||
.object({
|
||||
cached_tokens: z.number().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
completion_tokens_details: z
|
||||
.object({
|
||||
reasoning_tokens: z.number().nullish(),
|
||||
accepted_prediction_tokens: z.number().nullish(),
|
||||
rejected_prediction_tokens: z.number().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish()
|
||||
|
||||
// limited version of the schema, focussed on what is needed for the implementation
|
||||
// this approach limits breakages when the API changes and increases efficiency
|
||||
const OpenAICompatibleChatResponseSchema = z.object({
|
||||
id: z.string().nullish(),
|
||||
created: z.number().nullish(),
|
||||
model: z.string().nullish(),
|
||||
choices: z.array(
|
||||
z.object({
|
||||
message: z.object({
|
||||
role: z.literal("assistant").nullish(),
|
||||
content: z.string().nullish(),
|
||||
// Copilot-specific reasoning fields
|
||||
reasoning_text: z.string().nullish(),
|
||||
reasoning_opaque: z.string().nullish(),
|
||||
tool_calls: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().nullish(),
|
||||
function: z.object({
|
||||
name: z.string(),
|
||||
arguments: z.string(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
}),
|
||||
finish_reason: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
usage: openaiCompatibleTokenUsageSchema,
|
||||
})
|
||||
|
||||
// limited version of the schema, focussed on what is needed for the implementation
|
||||
// this approach limits breakages when the API changes and increases efficiency
|
||||
const createOpenAICompatibleChatChunkSchema = <ERROR_SCHEMA extends z.core.$ZodType>(errorSchema: ERROR_SCHEMA) =>
|
||||
z.union([
|
||||
z.object({
|
||||
id: z.string().nullish(),
|
||||
created: z.number().nullish(),
|
||||
model: z.string().nullish(),
|
||||
choices: z.array(
|
||||
z.object({
|
||||
delta: z
|
||||
.object({
|
||||
role: z.enum(["assistant"]).nullish(),
|
||||
content: z.string().nullish(),
|
||||
// Copilot-specific reasoning fields
|
||||
reasoning_text: z.string().nullish(),
|
||||
reasoning_opaque: z.string().nullish(),
|
||||
tool_calls: z
|
||||
.array(
|
||||
z.object({
|
||||
index: z.number(),
|
||||
id: z.string().nullish(),
|
||||
function: z.object({
|
||||
name: z.string().nullish(),
|
||||
arguments: z.string().nullish(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
finish_reason: z.string().nullish(),
|
||||
}),
|
||||
),
|
||||
usage: openaiCompatibleTokenUsageSchema,
|
||||
}),
|
||||
errorSchema,
|
||||
])
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export type OpenAICompatibleChatModelId = string
|
||||
|
||||
export const openaiCompatibleProviderOptions = z.object({
|
||||
/**
|
||||
* A unique identifier representing your end-user, which can help the provider to
|
||||
* monitor and detect abuse.
|
||||
*/
|
||||
user: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Reasoning effort for reasoning models. Defaults to `medium`.
|
||||
*/
|
||||
reasoningEffort: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Controls the verbosity of the generated text. Defaults to `medium`.
|
||||
*/
|
||||
textVerbosity: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Copilot thinking_budget used for Anthropic models.
|
||||
*/
|
||||
thinking_budget: z.number().optional(),
|
||||
})
|
||||
|
||||
export type OpenAICompatibleProviderOptions = z.infer<typeof openaiCompatibleProviderOptions>
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SharedV2ProviderMetadata } from "@ai-sdk/provider"
|
||||
|
||||
/**
|
||||
Extracts provider-specific metadata from API responses.
|
||||
Used to standardize metadata handling across different LLM providers while allowing
|
||||
provider-specific metadata to be captured.
|
||||
*/
|
||||
export type MetadataExtractor = {
|
||||
/**
|
||||
* Extracts provider metadata from a complete, non-streaming response.
|
||||
*
|
||||
* @param parsedBody - The parsed response JSON body from the provider's API.
|
||||
*
|
||||
* @returns Provider-specific metadata or undefined if no metadata is available.
|
||||
* The metadata should be under a key indicating the provider id.
|
||||
*/
|
||||
extractMetadata: ({ parsedBody }: { parsedBody: unknown }) => Promise<SharedV2ProviderMetadata | undefined>
|
||||
|
||||
/**
|
||||
* Creates an extractor for handling streaming responses. The returned object provides
|
||||
* methods to process individual chunks and build the final metadata from the accumulated
|
||||
* stream data.
|
||||
*
|
||||
* @returns An object with methods to process chunks and build metadata from a stream
|
||||
*/
|
||||
createStreamExtractor: () => {
|
||||
/**
|
||||
* Process an individual chunk from the stream. Called for each chunk in the response stream
|
||||
* to accumulate metadata throughout the streaming process.
|
||||
*
|
||||
* @param parsedChunk - The parsed JSON response chunk from the provider's API
|
||||
*/
|
||||
processChunk(parsedChunk: unknown): void
|
||||
|
||||
/**
|
||||
* Builds the metadata object after all chunks have been processed.
|
||||
* Called at the end of the stream to generate the complete provider metadata.
|
||||
*
|
||||
* @returns Provider-specific metadata or undefined if no metadata is available.
|
||||
* The metadata should be under a key indicating the provider id.
|
||||
*/
|
||||
buildMetadata(): SharedV2ProviderMetadata | undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
type LanguageModelV2CallOptions,
|
||||
type LanguageModelV2CallWarning,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
|
||||
export function prepareTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
}: {
|
||||
tools: LanguageModelV2CallOptions["tools"]
|
||||
toolChoice?: LanguageModelV2CallOptions["toolChoice"]
|
||||
}): {
|
||||
tools:
|
||||
| undefined
|
||||
| Array<{
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
description: string | undefined
|
||||
parameters: unknown
|
||||
}
|
||||
}>
|
||||
toolChoice: { type: "function"; function: { name: string } } | "auto" | "none" | "required" | undefined
|
||||
toolWarnings: LanguageModelV2CallWarning[]
|
||||
} {
|
||||
// when the tools array is empty, change it to undefined to prevent errors:
|
||||
tools = tools?.length ? tools : undefined
|
||||
|
||||
const toolWarnings: LanguageModelV2CallWarning[] = []
|
||||
|
||||
if (tools == null) {
|
||||
return { tools: undefined, toolChoice: undefined, toolWarnings }
|
||||
}
|
||||
|
||||
const openaiCompatTools: Array<{
|
||||
type: "function"
|
||||
function: {
|
||||
name: string
|
||||
description: string | undefined
|
||||
parameters: unknown
|
||||
}
|
||||
}> = []
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool.type === "provider-defined") {
|
||||
toolWarnings.push({ type: "unsupported-tool", tool })
|
||||
} else {
|
||||
openaiCompatTools.push({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (toolChoice == null) {
|
||||
return { tools: openaiCompatTools, toolChoice: undefined, toolWarnings }
|
||||
}
|
||||
|
||||
const type = toolChoice.type
|
||||
|
||||
switch (type) {
|
||||
case "auto":
|
||||
case "none":
|
||||
case "required":
|
||||
return { tools: openaiCompatTools, toolChoice: type, toolWarnings }
|
||||
case "tool":
|
||||
return {
|
||||
tools: openaiCompatTools,
|
||||
toolChoice: {
|
||||
type: "function",
|
||||
function: { name: toolChoice.toolName },
|
||||
},
|
||||
toolWarnings,
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = type
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `tool choice type: ${_exhaustiveCheck}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LanguageModelV2 } from "@ai-sdk/provider"
|
||||
import { OpenAICompatibleChatLanguageModel } from "@ai-sdk/openai-compatible"
|
||||
import { type FetchFunction, withoutTrailingSlash, withUserAgentSuffix } from "@ai-sdk/provider-utils"
|
||||
import { OpenAICompatibleChatLanguageModel } from "./chat/openai-compatible-chat-language-model"
|
||||
import { OpenAIResponsesLanguageModel } from "./responses/openai-responses-language-model"
|
||||
|
||||
// Import the version or define it
|
||||
2
packages/opencode/src/provider/sdk/copilot/index.ts
Normal file
2
packages/opencode/src/provider/sdk/copilot/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { createOpenaiCompatible, openaiCompatible } from "./copilot-provider"
|
||||
export type { OpenaiCompatibleProvider, OpenaiCompatibleProviderSettings } from "./copilot-provider"
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z, type ZodType } from "zod/v4"
|
||||
|
||||
export const openaiCompatibleErrorDataSchema = z.object({
|
||||
error: z.object({
|
||||
message: z.string(),
|
||||
|
||||
// The additional information below is handled loosely to support
|
||||
// OpenAI-compatible providers that have slightly different error
|
||||
// responses:
|
||||
type: z.string().nullish(),
|
||||
param: z.any().nullish(),
|
||||
code: z.union([z.string(), z.number()]).nullish(),
|
||||
}),
|
||||
})
|
||||
|
||||
export type OpenAICompatibleErrorData = z.infer<typeof openaiCompatibleErrorDataSchema>
|
||||
|
||||
export type ProviderErrorStructure<T> = {
|
||||
errorSchema: ZodType<T>
|
||||
errorToMessage: (error: T) => string
|
||||
isRetryable?: (response: Response, error?: T) => boolean
|
||||
}
|
||||
|
||||
export const defaultOpenAICompatibleErrorStructure: ProviderErrorStructure<OpenAICompatibleErrorData> = {
|
||||
errorSchema: openaiCompatibleErrorDataSchema,
|
||||
errorToMessage: (data) => data.error.message,
|
||||
}
|
||||
@@ -183,7 +183,7 @@ export async function convertToOpenAIResponsesInput({
|
||||
|
||||
case "reasoning": {
|
||||
const providerOptions = await parseProviderOptions({
|
||||
provider: "openai",
|
||||
provider: "copilot",
|
||||
providerOptions: part.providerOptions,
|
||||
schema: openaiResponsesReasoningProviderOptionsSchema,
|
||||
})
|
||||
@@ -194,7 +194,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
|
||||
}
|
||||
|
||||
const openaiOptions = await parseProviderOptions({
|
||||
provider: "openai",
|
||||
provider: "copilot",
|
||||
providerOptions,
|
||||
schema: openaiResponsesProviderOptionsSchema,
|
||||
})
|
||||
@@ -1,2 +0,0 @@
|
||||
export { createOpenaiCompatible, openaiCompatible } from "./openai-compatible-provider"
|
||||
export type { OpenaiCompatibleProvider, OpenaiCompatibleProviderSettings } from "./openai-compatible-provider"
|
||||
@@ -20,6 +20,7 @@ export namespace ProviderTransform {
|
||||
function sdkKey(npm: string): string | undefined {
|
||||
switch (npm) {
|
||||
case "@ai-sdk/github-copilot":
|
||||
return "copilot"
|
||||
case "@ai-sdk/openai":
|
||||
case "@ai-sdk/azure":
|
||||
return "openai"
|
||||
@@ -82,7 +83,11 @@ export namespace ProviderTransform {
|
||||
return msg
|
||||
})
|
||||
}
|
||||
if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) {
|
||||
if (
|
||||
model.providerID === "mistral" ||
|
||||
model.api.id.toLowerCase().includes("mistral") ||
|
||||
model.api.id.toLocaleLowerCase().includes("devstral")
|
||||
) {
|
||||
const result: ModelMessage[] = []
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
const msg = msgs[i]
|
||||
@@ -179,6 +184,9 @@ export namespace ProviderTransform {
|
||||
openaiCompatible: {
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
copilot: {
|
||||
copilot_cache_control: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
@@ -353,6 +361,15 @@ export namespace ProviderTransform {
|
||||
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
|
||||
|
||||
case "@ai-sdk/github-copilot":
|
||||
if (model.id.includes("gemini")) {
|
||||
// currently github copilot only returns thinking
|
||||
return {}
|
||||
}
|
||||
if (model.id.includes("claude")) {
|
||||
return {
|
||||
thinking: { thinking_budget: 4000 },
|
||||
}
|
||||
}
|
||||
const copilotEfforts = iife(() => {
|
||||
if (id.includes("5.1-codex-max") || id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
|
||||
return WIDELY_SUPPORTED_EFFORTS
|
||||
@@ -377,6 +394,31 @@ export namespace ProviderTransform {
|
||||
case "@ai-sdk/deepinfra":
|
||||
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
|
||||
case "@ai-sdk/openai-compatible":
|
||||
// When using openai-compatible SDK with Claude/Anthropic models,
|
||||
// we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names
|
||||
// and the OpenAI-compatible API spec uses snake_case
|
||||
if (
|
||||
model.providerID === "anthropic" ||
|
||||
model.api.id.includes("anthropic") ||
|
||||
model.api.id.includes("claude") ||
|
||||
model.id.includes("anthropic") ||
|
||||
model.id.includes("claude")
|
||||
) {
|
||||
return {
|
||||
high: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 16000,
|
||||
},
|
||||
},
|
||||
max: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 31999,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
|
||||
|
||||
case "@ai-sdk/azure":
|
||||
@@ -656,9 +698,21 @@ export namespace ProviderTransform {
|
||||
const modelCap = modelLimit || globalLimit
|
||||
const standardLimit = Math.min(modelCap, globalLimit)
|
||||
|
||||
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
|
||||
// Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens)
|
||||
// and @ai-sdk/openai-compatible with Claude (budget_tokens)
|
||||
if (
|
||||
npm === "@ai-sdk/anthropic" ||
|
||||
npm === "@ai-sdk/google-vertex/anthropic" ||
|
||||
npm === "@ai-sdk/openai-compatible"
|
||||
) {
|
||||
const thinking = options?.["thinking"]
|
||||
const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
|
||||
// Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible)
|
||||
const budgetTokens =
|
||||
typeof thinking?.["budgetTokens"] === "number"
|
||||
? thinking["budgetTokens"]
|
||||
: typeof thinking?.["budget_tokens"] === "number"
|
||||
? thinking["budget_tokens"]
|
||||
: 0
|
||||
const enabled = thinking?.["type"] === "enabled"
|
||||
if (enabled && budgetTokens > 0) {
|
||||
// Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.
|
||||
|
||||
@@ -148,14 +148,15 @@ export namespace LLM {
|
||||
},
|
||||
)
|
||||
|
||||
const maxOutputTokens = isCodex
|
||||
? undefined
|
||||
: ProviderTransform.maxOutputTokens(
|
||||
input.model.api.npm,
|
||||
params.options,
|
||||
input.model.limit.output,
|
||||
OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
const maxOutputTokens =
|
||||
isCodex || provider.id.includes("github-copilot")
|
||||
? undefined
|
||||
: ProviderTransform.maxOutputTokens(
|
||||
input.model.api.npm,
|
||||
params.options,
|
||||
input.model.limit.output,
|
||||
OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
|
||||
const tools = await resolveTools(input)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export namespace Skill {
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
location: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
@@ -74,6 +75,7 @@ export namespace Skill {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
location: match,
|
||||
content: md.content,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,15 +37,7 @@ export const GrepTool = Tool.define("grep", {
|
||||
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
const rgPath = await Ripgrep.filepath()
|
||||
const args = [
|
||||
"-nH",
|
||||
"--hidden",
|
||||
"--follow",
|
||||
"--no-messages",
|
||||
"--field-match-separator=|",
|
||||
"--regexp",
|
||||
params.pattern,
|
||||
]
|
||||
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
|
||||
if (params.include) {
|
||||
args.push("--glob", params.include)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import path from "path"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { Skill } from "../skill"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { PermissionNext } from "../permission/next"
|
||||
|
||||
export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
@@ -62,7 +61,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
const content = (await ConfigMarkdown.parse(skill.location)).content
|
||||
const content = skill.content
|
||||
const dir = path.dirname(skill.location)
|
||||
|
||||
// Format output similar to plugin pattern
|
||||
|
||||
@@ -25,6 +25,7 @@ process.env["XDG_DATA_HOME"] = path.join(dir, "share")
|
||||
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
|
||||
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
|
||||
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
|
||||
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
|
||||
|
||||
// Write the cache version file to prevent global/index.ts from clearing the cache
|
||||
const cacheDir = path.join(dir, "cache", "opencode")
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages"
|
||||
import { describe, test, expect } from "bun:test"
|
||||
|
||||
describe("user messages", () => {
|
||||
test("should convert messages with only a text part to a string content", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([{ role: "user", content: "Hello" }])
|
||||
})
|
||||
|
||||
test("should convert messages with image parts", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{
|
||||
type: "file",
|
||||
data: Buffer.from([0, 1, 2, 3]).toString("base64"),
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: "data:image/png;base64,AAECAw==" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should convert messages with image parts from Uint8Array", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hi" },
|
||||
{
|
||||
type: "file",
|
||||
data: new Uint8Array([0, 1, 2, 3]),
|
||||
mediaType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hi" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: "data:image/png;base64,AAECAw==" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle URL-based images", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
data: new URL("https://example.com/image.jpg"),
|
||||
mediaType: "image/*",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: { url: "https://example.com/image.jpg" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle multiple text parts without flattening", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Part 1" },
|
||||
{ type: "text", text: "Part 2" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Part 1" },
|
||||
{ type: "text", text: "Part 2" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("assistant messages", () => {
|
||||
test("should convert assistant text messages", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello back!" }],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hello back!",
|
||||
tool_calls: undefined,
|
||||
reasoning_text: undefined,
|
||||
reasoning_opaque: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle assistant message with null content when only tool calls", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call1",
|
||||
toolName: "calculator",
|
||||
input: { a: 1, b: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "calculator",
|
||||
arguments: JSON.stringify({ a: 1, b: 2 }),
|
||||
},
|
||||
},
|
||||
],
|
||||
reasoning_text: undefined,
|
||||
reasoning_opaque: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should concatenate multiple text parts", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "First part. " },
|
||||
{ type: "text", text: "Second part." },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result[0].content).toBe("First part. Second part.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool calls", () => {
|
||||
test("should stringify arguments to tool calls", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
input: { foo: "bar123" },
|
||||
toolCallId: "quux",
|
||||
toolName: "thwomp",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "quux",
|
||||
toolName: "thwomp",
|
||||
output: { type: "json", value: { oof: "321rab" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "quux",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "thwomp",
|
||||
arguments: JSON.stringify({ foo: "bar123" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
reasoning_text: undefined,
|
||||
reasoning_opaque: undefined,
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "quux",
|
||||
content: JSON.stringify({ oof: "321rab" }),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle text output type in tool results", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call-1",
|
||||
toolName: "getWeather",
|
||||
output: { type: "text", value: "It is sunny today" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "tool",
|
||||
tool_call_id: "call-1",
|
||||
content: "It is sunny today",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle multiple tool results as separate messages", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call1",
|
||||
toolName: "api1",
|
||||
output: { type: "text", value: "Result 1" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call2",
|
||||
toolName: "api2",
|
||||
output: { type: "text", value: "Result 2" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({
|
||||
role: "tool",
|
||||
tool_call_id: "call1",
|
||||
content: "Result 1",
|
||||
})
|
||||
expect(result[1]).toEqual({
|
||||
role: "tool",
|
||||
tool_call_id: "call2",
|
||||
content: "Result 2",
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle text plus multiple tool calls", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Checking... " },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call1",
|
||||
toolName: "searchTool",
|
||||
input: { query: "Weather" },
|
||||
},
|
||||
{ type: "text", text: "Almost there..." },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call2",
|
||||
toolName: "mapsTool",
|
||||
input: { location: "Paris" },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Checking... Almost there...",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "searchTool",
|
||||
arguments: JSON.stringify({ query: "Weather" }),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "call2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "mapsTool",
|
||||
arguments: JSON.stringify({ location: "Paris" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
reasoning_text: undefined,
|
||||
reasoning_opaque: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("reasoning (copilot-specific)", () => {
|
||||
test("should include reasoning_text from reasoning part", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "Let me think about this..." },
|
||||
{ type: "text", text: "The answer is 42." },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "The answer is 42.",
|
||||
tool_calls: undefined,
|
||||
reasoning_text: "Let me think about this...",
|
||||
reasoning_opaque: undefined,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should include reasoning_opaque from providerOptions", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Thinking...",
|
||||
providerOptions: {
|
||||
copilot: { reasoningOpaque: "opaque-signature-123" },
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "Done!" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Done!",
|
||||
tool_calls: undefined,
|
||||
reasoning_text: "Thinking...",
|
||||
reasoning_opaque: "opaque-signature-123",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle reasoning-only assistant message", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Just thinking, no response yet",
|
||||
providerOptions: {
|
||||
copilot: { reasoningOpaque: "sig-abc" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: undefined,
|
||||
reasoning_text: "Just thinking, no response yet",
|
||||
reasoning_opaque: "sig-abc",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("full conversation", () => {
|
||||
test("should convert a multi-turn conversation with reasoning", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What is 2+2?" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Let me calculate 2+2...",
|
||||
providerOptions: {
|
||||
copilot: { reasoningOpaque: "sig-abc" },
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "2+2 equals 4." },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What about 3+3?" }],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
|
||||
const systemMsg = result[0]
|
||||
expect(systemMsg.role).toBe("system")
|
||||
|
||||
// Assistant message should have reasoning fields
|
||||
const assistantMsg = result[2] as {
|
||||
reasoning_text?: string
|
||||
reasoning_opaque?: string
|
||||
}
|
||||
expect(assistantMsg.reasoning_text).toBe("Let me calculate 2+2...")
|
||||
expect(assistantMsg.reasoning_opaque).toBe("sig-abc")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,557 @@
|
||||
import { OpenAICompatibleChatLanguageModel } from "@/provider/sdk/copilot/chat/openai-compatible-chat-language-model"
|
||||
import { describe, test, expect, mock } from "bun:test"
|
||||
import type { LanguageModelV2Prompt } from "@ai-sdk/provider"
|
||||
|
||||
async function convertReadableStreamToArray<T>(stream: ReadableStream<T>): Promise<T[]> {
|
||||
const reader = stream.getReader()
|
||||
const result: T[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
result.push(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const TEST_PROMPT: LanguageModelV2Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
|
||||
|
||||
// Fixtures from copilot_test.exs
|
||||
const FIXTURES = {
|
||||
basicText: [
|
||||
`data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}`,
|
||||
`data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":"stop"}]}`,
|
||||
`data: [DONE]`,
|
||||
],
|
||||
|
||||
reasoningWithToolCalls: [
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Understanding Dayzee's Purpose**\\n\\nI'm starting to get a better handle on \`dayzee\`.\\n\\n"}}],"created":1764940861,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Assessing Dayzee's Functionality**\\n\\nI've reviewed the files.\\n\\n"}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"filePath\\":\\"/README.md\\"}","name":"read_file"},"id":"call_abc123","index":0,"type":"function"}],"reasoning_opaque":"4CUQ6696CwSXOdQ5rtvDimqA91tBzfmga4ieRbmZ5P67T2NLW3"}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
|
||||
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"filePath\\":\\"/mix.exs\\"}","name":"read_file"},"id":"call_def456","index":1,"type":"function"}]}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":53,"prompt_tokens":19581,"prompt_tokens_details":{"cached_tokens":17068},"total_tokens":19768,"reasoning_tokens":134},"model":"gemini-3-pro-preview"}`,
|
||||
`data: [DONE]`,
|
||||
],
|
||||
|
||||
reasoningWithOpaqueAtEnd: [
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Analyzing the Inquiry's Nature**\\n\\nI'm currently parsing the user's question.\\n\\n"}}],"created":1765201729,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Reconciling User's Input**\\n\\nI'm grappling with the context.\\n\\n"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":"I am Tidewave, a highly skilled AI coding agent.\\n\\n","role":"assistant"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
|
||||
`data: {"choices":[{"finish_reason":"stop","index":0,"delta":{"content":"How can I help you?","role":"assistant","reasoning_opaque":"/PMlTqxqSJZnUBDHgnnJKLVI4eZQ"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":59,"prompt_tokens":5778,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":5932,"reasoning_tokens":95},"model":"gemini-3-pro-preview"}`,
|
||||
`data: [DONE]`,
|
||||
],
|
||||
|
||||
// Case where reasoning_opaque and content come in the SAME chunk
|
||||
reasoningWithOpaqueAndContentSameChunk: [
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Understanding the Query's Nature**\\n\\nI'm currently grappling with the user's philosophical query.\\n\\n"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Framing the Response's Core**\\n\\nNow, I'm structuring my response.\\n\\n"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":"Of course. I'm thinking right now.","role":"assistant","reasoning_opaque":"ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
|
||||
`data: {"choices":[{"finish_reason":"stop","index":0,"delta":{"content":" What's on your mind?","role":"assistant"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":78,"prompt_tokens":3767,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":3915,"reasoning_tokens":70},"model":"gemini-2.5-pro"}`,
|
||||
`data: [DONE]`,
|
||||
],
|
||||
|
||||
// Case where reasoning_opaque and content come in same chunk, followed by tool calls
|
||||
reasoningWithOpaqueContentAndToolCalls: [
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Analyzing the Structure**\\n\\nI'm currently trying to get a handle on the project's layout. My initial focus is on the file structure itself, specifically the directory organization. I'm hoping this will illuminate how different components interact. I'll need to identify the key modules and their dependencies.\\n\\n\\n"}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":"Okay, I need to check out the project's file structure.","role":"assistant","reasoning_opaque":"WHOd3dYFnxEBOsKUXjbX6c2rJa0fS214FHbsj+A3Q+i63SFo7H/92RsownAzyo0h2qEy3cOcrvAatsMx51eCKiMSqt4dYWZhd5YVSgF0CehkpDbWBP/SoRqLU1dhCmUJV/6b5uYFBOzKLBGNadyhI7T1gWFlXntwc6SNjH6DujnFPeVr+L8DdOoUJGJrw2aOfm9NtkXA6wZh9t7dt+831yIIImjD9MHczuXoXj8K7tyLpIJ9KlVXMhnO4IKSYNdKRtoHlGTmudAp5MgH/vLWb6oSsL+ZJl/OdF3WBOeanGhYNoByCRDSvR7anAR/9m5zf9yUax+u/nFg+gzmhFacnzZGtSmcvJ4/4HWKNtUkRASTKeN94DXB8j1ptB/i6ldaMAz2ZyU+sbjPWI8aI4fKJ2MuO01u3uE87xVwpWiM+0rahIzJsllI5edwOaOFtF4tnlCTQafbxHwCZR62uON2E+IjGzW80MzyfYrbLBJKS5zTeHCgPYQSNaKzPfpzkQvdwo3JUnJYcEHgGeKzkq5sbvS5qitCYI7Xue0V98S6/KnUSPnDQBjNnas2i6BqJV2vuCEU/Y3ucrlKVbuRIFCZXCyLzrsGeRLRKlrf5S/HDAQ04IOPQVQhBPvhX0nDjhZB"}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`,
|
||||
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"list_project_files"},"id":"call_MHxqRDd5WVo3NU8wUXRaMmc0MFE","index":0,"type":"function"}]}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":19,"prompt_tokens":3767,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":3797,"reasoning_tokens":11},"model":"gemini-2.5-pro"}`,
|
||||
`data: [DONE]`,
|
||||
],
|
||||
|
||||
// Case where reasoning goes directly to tool_calls with NO content
|
||||
// reasoning_opaque and tool_calls come in the same chunk
|
||||
reasoningDirectlyToToolCalls: [
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Executing and Analyzing HTML**\\n\\nI've successfully captured the HTML snapshot using the \`browser_eval\` tool, giving me a solid understanding of the page structure. Now, I'm shifting focus to Elixir code execution with \`project_eval\` to assess my ability to work within the project's environment.\\n\\n\\n"}}],"created":1766068643,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Testing Project Contexts**\\n\\nI've got the HTML body snapshot from \`browser_eval\`, which is a helpful reference. Next, I'm testing my ability to run Elixir code in the project with \`project_eval\`. I'm starting with a simple sum: \`1 + 1\`. This will confirm I'm set up to interact with the project's codebase.\\n\\n\\n"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
|
||||
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`,
|
||||
`data: [DONE]`,
|
||||
],
|
||||
}
|
||||
|
||||
function createMockFetch(chunks: string[]) {
|
||||
return mock(async () => {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(new TextEncoder().encode(chunk + "\n\n"))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createModel(fetchFn: ReturnType<typeof mock>) {
|
||||
return new OpenAICompatibleChatLanguageModel("test-model", {
|
||||
provider: "copilot.chat",
|
||||
url: () => "https://api.test.com/chat/completions",
|
||||
headers: () => ({ Authorization: "Bearer test-token" }),
|
||||
fetch: fetchFn as any,
|
||||
})
|
||||
}
|
||||
|
||||
describe("doStream", () => {
|
||||
test("should stream text deltas", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.basicText)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
// Filter to just the key events
|
||||
const textParts = parts.filter(
|
||||
(p) => p.type === "text-start" || p.type === "text-delta" || p.type === "text-end" || p.type === "finish",
|
||||
)
|
||||
|
||||
expect(textParts).toMatchObject([
|
||||
{ type: "text-start", id: "txt-0" },
|
||||
{ type: "text-delta", id: "txt-0", delta: "Hello" },
|
||||
{ type: "text-delta", id: "txt-0", delta: " world" },
|
||||
{ type: "text-delta", id: "txt-0", delta: "!" },
|
||||
{ type: "text-end", id: "txt-0" },
|
||||
{ type: "finish", finishReason: "stop" },
|
||||
])
|
||||
})
|
||||
|
||||
test("should stream reasoning with tool calls and capture reasoning_opaque", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.reasoningWithToolCalls)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
// Check reasoning parts
|
||||
const reasoningParts = parts.filter(
|
||||
(p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end",
|
||||
)
|
||||
|
||||
expect(reasoningParts[0]).toEqual({
|
||||
type: "reasoning-start",
|
||||
id: "reasoning-0",
|
||||
})
|
||||
|
||||
expect(reasoningParts[1]).toMatchObject({
|
||||
type: "reasoning-delta",
|
||||
id: "reasoning-0",
|
||||
})
|
||||
expect((reasoningParts[1] as { delta: string }).delta).toContain("**Understanding Dayzee's Purpose**")
|
||||
|
||||
expect(reasoningParts[2]).toMatchObject({
|
||||
type: "reasoning-delta",
|
||||
id: "reasoning-0",
|
||||
})
|
||||
expect((reasoningParts[2] as { delta: string }).delta).toContain("**Assessing Dayzee's Functionality**")
|
||||
|
||||
// reasoning_opaque should be in reasoning-end providerMetadata
|
||||
const reasoningEnd = reasoningParts.find((p) => p.type === "reasoning-end")
|
||||
expect(reasoningEnd).toMatchObject({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
reasoningOpaque: "4CUQ6696CwSXOdQ5rtvDimqA91tBzfmga4ieRbmZ5P67T2NLW3",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Check tool calls
|
||||
const toolParts = parts.filter(
|
||||
(p) => p.type === "tool-input-start" || p.type === "tool-call" || p.type === "tool-input-end",
|
||||
)
|
||||
|
||||
expect(toolParts).toContainEqual({
|
||||
type: "tool-input-start",
|
||||
id: "call_abc123",
|
||||
toolName: "read_file",
|
||||
})
|
||||
|
||||
expect(toolParts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "tool-call",
|
||||
toolCallId: "call_abc123",
|
||||
toolName: "read_file",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(toolParts).toContainEqual({
|
||||
type: "tool-input-start",
|
||||
id: "call_def456",
|
||||
toolName: "read_file",
|
||||
})
|
||||
|
||||
// Check finish
|
||||
const finish = parts.find((p) => p.type === "finish")
|
||||
expect(finish).toMatchObject({
|
||||
type: "finish",
|
||||
finishReason: "tool-calls",
|
||||
usage: {
|
||||
inputTokens: 19581,
|
||||
outputTokens: 53,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle reasoning_opaque that comes at end with text in between", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueAtEnd)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
// Check that reasoning comes first
|
||||
const reasoningStart = parts.findIndex((p) => p.type === "reasoning-start")
|
||||
const textStart = parts.findIndex((p) => p.type === "text-start")
|
||||
expect(reasoningStart).toBeLessThan(textStart)
|
||||
|
||||
// Check reasoning deltas
|
||||
const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta")
|
||||
expect(reasoningDeltas).toHaveLength(2)
|
||||
expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Analyzing the Inquiry's Nature**")
|
||||
expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Reconciling User's Input**")
|
||||
|
||||
// Check text deltas
|
||||
const textDeltas = parts.filter((p) => p.type === "text-delta")
|
||||
expect(textDeltas).toHaveLength(2)
|
||||
expect((textDeltas[0] as { delta: string }).delta).toContain("I am Tidewave")
|
||||
expect((textDeltas[1] as { delta: string }).delta).toContain("How can I help you?")
|
||||
|
||||
// reasoning-end should be emitted before text-start
|
||||
const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end")
|
||||
const textStartIndex = parts.findIndex((p) => p.type === "text-start")
|
||||
expect(reasoningEndIndex).toBeGreaterThan(-1)
|
||||
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
|
||||
|
||||
// In this fixture, reasoning_opaque comes AFTER content has started (in chunk 4)
|
||||
// So it arrives too late to be attached to reasoning-end. But it should still
|
||||
// be captured and included in the finish event's providerMetadata.
|
||||
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
|
||||
expect(reasoningEnd).toMatchObject({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
})
|
||||
|
||||
// reasoning_opaque should be in the finish event's providerMetadata
|
||||
const finish = parts.find((p) => p.type === "finish")
|
||||
expect(finish).toMatchObject({
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: {
|
||||
inputTokens: 5778,
|
||||
outputTokens: 59,
|
||||
},
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
reasoningOpaque: "/PMlTqxqSJZnUBDHgnnJKLVI4eZQ",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle reasoning_opaque and content in the same chunk", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueAndContentSameChunk)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
// The critical test: reasoning-end should come BEFORE text-start
|
||||
const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end")
|
||||
const textStartIndex = parts.findIndex((p) => p.type === "text-start")
|
||||
expect(reasoningEndIndex).toBeGreaterThan(-1)
|
||||
expect(textStartIndex).toBeGreaterThan(-1)
|
||||
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
|
||||
|
||||
// Check reasoning deltas
|
||||
const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta")
|
||||
expect(reasoningDeltas).toHaveLength(2)
|
||||
expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Understanding the Query's Nature**")
|
||||
expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Framing the Response's Core**")
|
||||
|
||||
// reasoning_opaque should be in reasoning-end even though it came with content
|
||||
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
|
||||
expect(reasoningEnd).toMatchObject({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
reasoningOpaque: "ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Check text deltas
|
||||
const textDeltas = parts.filter((p) => p.type === "text-delta")
|
||||
expect(textDeltas).toHaveLength(2)
|
||||
expect((textDeltas[0] as { delta: string }).delta).toContain("Of course. I'm thinking right now.")
|
||||
expect((textDeltas[1] as { delta: string }).delta).toContain("What's on your mind?")
|
||||
|
||||
// Check finish
|
||||
const finish = parts.find((p) => p.type === "finish")
|
||||
expect(finish).toMatchObject({
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle reasoning_opaque and content followed by tool calls", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueContentAndToolCalls)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
// Check that reasoning comes first, then text, then tool calls
|
||||
const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end")
|
||||
const textStartIndex = parts.findIndex((p) => p.type === "text-start")
|
||||
const toolStartIndex = parts.findIndex((p) => p.type === "tool-input-start")
|
||||
|
||||
expect(reasoningEndIndex).toBeGreaterThan(-1)
|
||||
expect(textStartIndex).toBeGreaterThan(-1)
|
||||
expect(toolStartIndex).toBeGreaterThan(-1)
|
||||
expect(reasoningEndIndex).toBeLessThan(textStartIndex)
|
||||
expect(textStartIndex).toBeLessThan(toolStartIndex)
|
||||
|
||||
// Check reasoning content
|
||||
const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta")
|
||||
expect(reasoningDeltas).toHaveLength(1)
|
||||
expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Analyzing the Structure**")
|
||||
|
||||
// reasoning_opaque should be in reasoning-end (comes with content in same chunk)
|
||||
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
|
||||
expect(reasoningEnd).toMatchObject({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
reasoningOpaque: expect.stringContaining("WHOd3dYFnxEBOsKUXjbX6c2rJa0fS214"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Check text content
|
||||
const textDeltas = parts.filter((p) => p.type === "text-delta")
|
||||
expect(textDeltas).toHaveLength(1)
|
||||
expect((textDeltas[0] as { delta: string }).delta).toContain(
|
||||
"Okay, I need to check out the project's file structure.",
|
||||
)
|
||||
|
||||
// Check tool call
|
||||
const toolParts = parts.filter(
|
||||
(p) => p.type === "tool-input-start" || p.type === "tool-call" || p.type === "tool-input-end",
|
||||
)
|
||||
|
||||
expect(toolParts).toContainEqual({
|
||||
type: "tool-input-start",
|
||||
id: "call_MHxqRDd5WVo3NU8wUXRaMmc0MFE",
|
||||
toolName: "list_project_files",
|
||||
})
|
||||
|
||||
expect(toolParts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "tool-call",
|
||||
toolCallId: "call_MHxqRDd5WVo3NU8wUXRaMmc0MFE",
|
||||
toolName: "list_project_files",
|
||||
}),
|
||||
)
|
||||
|
||||
// Check finish
|
||||
const finish = parts.find((p) => p.type === "finish")
|
||||
expect(finish).toMatchObject({
|
||||
type: "finish",
|
||||
finishReason: "tool-calls",
|
||||
usage: {
|
||||
inputTokens: 3767,
|
||||
outputTokens: 19,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should emit reasoning-end before tool-input-start when reasoning goes directly to tool calls", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.reasoningDirectlyToToolCalls)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
// Critical check: reasoning-end MUST come before tool-input-start
|
||||
const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end")
|
||||
const toolStartIndex = parts.findIndex((p) => p.type === "tool-input-start")
|
||||
|
||||
expect(reasoningEndIndex).toBeGreaterThan(-1)
|
||||
expect(toolStartIndex).toBeGreaterThan(-1)
|
||||
expect(reasoningEndIndex).toBeLessThan(toolStartIndex)
|
||||
|
||||
// Check reasoning parts
|
||||
const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta")
|
||||
expect(reasoningDeltas).toHaveLength(2)
|
||||
expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Executing and Analyzing HTML**")
|
||||
expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Testing Project Contexts**")
|
||||
|
||||
// reasoning_opaque should be in reasoning-end providerMetadata
|
||||
const reasoningEnd = parts.find((p) => p.type === "reasoning-end")
|
||||
expect(reasoningEnd).toMatchObject({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
reasoningOpaque: "ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// No text parts should exist
|
||||
const textParts = parts.filter((p) => p.type === "text-start" || p.type === "text-delta" || p.type === "text-end")
|
||||
expect(textParts).toHaveLength(0)
|
||||
|
||||
// Check tool call
|
||||
const toolCall = parts.find((p) => p.type === "tool-call")
|
||||
expect(toolCall).toMatchObject({
|
||||
type: "tool-call",
|
||||
toolCallId: "call_MHw3RDhmT1J5Z3B6WlhpVjlveTc",
|
||||
toolName: "project_eval",
|
||||
})
|
||||
|
||||
// Check finish
|
||||
const finish = parts.find((p) => p.type === "finish")
|
||||
expect(finish).toMatchObject({
|
||||
type: "finish",
|
||||
finishReason: "tool-calls",
|
||||
})
|
||||
})
|
||||
|
||||
test("should include response metadata from first chunk", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.basicText)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
const metadata = parts.find((p) => p.type === "response-metadata")
|
||||
expect(metadata).toMatchObject({
|
||||
type: "response-metadata",
|
||||
id: "chatcmpl-123",
|
||||
modelId: "gemini-2.0-flash-001",
|
||||
})
|
||||
})
|
||||
|
||||
test("should emit stream-start with warnings", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.basicText)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
const streamStart = parts.find((p) => p.type === "stream-start")
|
||||
expect(streamStart).toEqual({
|
||||
type: "stream-start",
|
||||
warnings: [],
|
||||
})
|
||||
})
|
||||
|
||||
test("should include raw chunks when requested", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.basicText)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: true,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
|
||||
const rawChunks = parts.filter((p) => p.type === "raw")
|
||||
expect(rawChunks.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("request body", () => {
|
||||
test("should send tools in OpenAI format", async () => {
|
||||
let capturedBody: unknown
|
||||
const mockFetch = mock(async (_url: string, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(init?.body as string)
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(`data: [DONE]\n\n`))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "text/event-stream" } },
|
||||
)
|
||||
})
|
||||
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "get_weather",
|
||||
description: "Get the weather for a location",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
},
|
||||
],
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
expect((capturedBody as { tools: unknown[] }).tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the weather for a location",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -267,6 +267,76 @@ describe("ProviderTransform.maxOutputTokens", () => {
|
||||
expect(result).toBe(OUTPUT_TOKEN_MAX)
|
||||
})
|
||||
})
|
||||
|
||||
describe("openai-compatible with thinking options (snake_case)", () => {
|
||||
test("returns 32k when budget_tokens + 32k <= modelLimit", () => {
|
||||
const modelLimit = 100000
|
||||
const options = {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 10000,
|
||||
},
|
||||
}
|
||||
const result = ProviderTransform.maxOutputTokens(
|
||||
"@ai-sdk/openai-compatible",
|
||||
options,
|
||||
modelLimit,
|
||||
OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
expect(result).toBe(OUTPUT_TOKEN_MAX)
|
||||
})
|
||||
|
||||
test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => {
|
||||
const modelLimit = 50000
|
||||
const options = {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 30000,
|
||||
},
|
||||
}
|
||||
const result = ProviderTransform.maxOutputTokens(
|
||||
"@ai-sdk/openai-compatible",
|
||||
options,
|
||||
modelLimit,
|
||||
OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
expect(result).toBe(20000)
|
||||
})
|
||||
|
||||
test("returns 32k when thinking type is not enabled", () => {
|
||||
const modelLimit = 100000
|
||||
const options = {
|
||||
thinking: {
|
||||
type: "disabled",
|
||||
budget_tokens: 10000,
|
||||
},
|
||||
}
|
||||
const result = ProviderTransform.maxOutputTokens(
|
||||
"@ai-sdk/openai-compatible",
|
||||
options,
|
||||
modelLimit,
|
||||
OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
expect(result).toBe(OUTPUT_TOKEN_MAX)
|
||||
})
|
||||
|
||||
test("returns 32k when budget_tokens is 0", () => {
|
||||
const modelLimit = 100000
|
||||
const options = {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 0,
|
||||
},
|
||||
}
|
||||
const result = ProviderTransform.maxOutputTokens(
|
||||
"@ai-sdk/openai-compatible",
|
||||
options,
|
||||
modelLimit,
|
||||
OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
expect(result).toBe(OUTPUT_TOKEN_MAX)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.schema - gemini array items", () => {
|
||||
@@ -1031,21 +1101,21 @@ describe("ProviderTransform.message - providerOptions key remapping", () => {
|
||||
expect(result[0].providerOptions?.openai).toBeUndefined()
|
||||
})
|
||||
|
||||
test("openai with github-copilot npm remaps providerID to 'openai'", () => {
|
||||
test("copilot remaps providerID to 'copilot' key", () => {
|
||||
const model = createModel("github-copilot", "@ai-sdk/github-copilot")
|
||||
const msgs = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
providerOptions: {
|
||||
"github-copilot": { someOption: "value" },
|
||||
copilot: { someOption: "value" },
|
||||
},
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, model, {})
|
||||
|
||||
expect(result[0].providerOptions?.openai).toEqual({ someOption: "value" })
|
||||
expect(result[0].providerOptions?.copilot).toEqual({ someOption: "value" })
|
||||
expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -1494,6 +1564,67 @@ describe("ProviderTransform.variants", () => {
|
||||
expect(result.low).toEqual({ reasoningEffort: "low" })
|
||||
expect(result.high).toEqual({ reasoningEffort: "high" })
|
||||
})
|
||||
|
||||
test("Claude via LiteLLM returns thinking with snake_case budget_tokens", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-sonnet-4-5",
|
||||
providerID: "anthropic",
|
||||
api: {
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
url: "http://localhost:4000",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 16000,
|
||||
},
|
||||
})
|
||||
expect(result.max).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 31999,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Claude model (by model.id) via openai-compatible uses snake_case", () => {
|
||||
const model = createMockModel({
|
||||
id: "litellm/claude-3-opus",
|
||||
providerID: "litellm",
|
||||
api: {
|
||||
id: "claude-3-opus-20240229",
|
||||
url: "http://localhost:4000",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high).toEqual({
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 16000,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Anthropic model (by model.api.id) via openai-compatible uses snake_case", () => {
|
||||
const model = createMockModel({
|
||||
id: "custom/my-model",
|
||||
providerID: "custom",
|
||||
api: {
|
||||
id: "anthropic.claude-sonnet",
|
||||
url: "http://localhost:4000",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["high", "max"])
|
||||
expect(result.high.thinking.budget_tokens).toBe(16000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/azure", () => {
|
||||
|
||||
@@ -197,12 +197,6 @@ async function loadFixture(providerID: string, modelID: string) {
|
||||
return { provider, model }
|
||||
}
|
||||
|
||||
async function writeModels(models: Record<string, ModelsDev.Provider>) {
|
||||
const modelsPath = path.join(Global.Path.cache, "models.json")
|
||||
await Bun.write(modelsPath, JSON.stringify(models))
|
||||
ModelsDev.Data.reset()
|
||||
}
|
||||
|
||||
function createEventStream(chunks: unknown[], includeDone = false) {
|
||||
const lines = chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`)
|
||||
if (includeDone) {
|
||||
@@ -246,8 +240,6 @@ describe("session.llm.stream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
await writeModels({ [providerID]: provider })
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
@@ -342,7 +334,7 @@ describe("session.llm.stream", () => {
|
||||
throw new Error("Server not initialized")
|
||||
}
|
||||
|
||||
const source = await loadFixture("github-copilot", "gpt-5.1")
|
||||
const source = await loadFixture("openai", "gpt-5.2")
|
||||
const model = source.model
|
||||
|
||||
const responseChunks = [
|
||||
@@ -377,8 +369,6 @@ describe("session.llm.stream", () => {
|
||||
]
|
||||
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
|
||||
|
||||
await writeModels({})
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
@@ -513,8 +503,6 @@ describe("session.llm.stream", () => {
|
||||
]
|
||||
const request = waitRequest("/messages", createEventResponse(chunks))
|
||||
|
||||
await writeModels({ [providerID]: provider })
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
@@ -623,8 +611,6 @@ describe("session.llm.stream", () => {
|
||||
]
|
||||
const request = waitRequest(pathSuffix, createEventResponse(chunks))
|
||||
|
||||
await writeModels({ [providerID]: provider })
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -173,7 +173,9 @@ describe("tool.read truncation", () => {
|
||||
test("truncates large file by bytes and sets truncated metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const base = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text()
|
||||
const target = 60 * 1024
|
||||
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
|
||||
await Bun.write(path.join(dir, "large.json"), content)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2116,7 +2116,7 @@ export type Command = {
|
||||
description?: string
|
||||
agent?: string
|
||||
model?: string
|
||||
mcp?: boolean
|
||||
source?: "command" | "mcp" | "skill"
|
||||
template: string
|
||||
subtask?: boolean
|
||||
hints: Array<string>
|
||||
@@ -4913,6 +4913,7 @@ export type AppSkillsResponses = {
|
||||
name: string
|
||||
description: string
|
||||
location: string
|
||||
content: string
|
||||
}>
|
||||
}
|
||||
|
||||
|
||||
@@ -5723,9 +5723,12 @@
|
||||
},
|
||||
"location": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name", "description", "location"]
|
||||
"required": ["name", "description", "location", "content"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10770,8 +10773,9 @@
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"mcp": {
|
||||
"type": "boolean"
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["command", "mcp", "skill"]
|
||||
},
|
||||
"template": {
|
||||
"anyOf": [
|
||||
|
||||
@@ -36,9 +36,7 @@
|
||||
border-radius: var(--radius-md);
|
||||
overflow: clip;
|
||||
color: var(--text-strong);
|
||||
transition-property: background-color, border-color;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
/* text-12-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
@@ -60,48 +58,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-arrow"] {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
&[data-expanded] {
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"] {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition-property: grid-template-rows, opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
width: 100%;
|
||||
|
||||
> * {
|
||||
overflow: hidden;
|
||||
[data-slot="accordion-content"] {
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-top: none;
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"][data-expanded] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"][data-closed] {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
&[data-expanded] [data-slot="accordion-trigger"] {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
&[data-expanded] [data-slot="accordion-content"] {
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-top: none;
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
height: auto;
|
||||
[data-slot="accordion-content"] {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--kb-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--kb-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Accordion as Kobalte } from "@kobalte/core/accordion"
|
||||
import { Accessor, createContext, splitProps, useContext } from "solid-js"
|
||||
import { splitProps } from "solid-js"
|
||||
import type { ComponentProps, ParentProps } from "solid-js"
|
||||
import { MorphChevron } from "./morph-chevron"
|
||||
|
||||
export interface AccordionProps extends ComponentProps<typeof Kobalte> {}
|
||||
export interface AccordionItemProps extends ComponentProps<typeof Kobalte.Item> {}
|
||||
@@ -9,8 +8,6 @@ export interface AccordionHeaderProps extends ComponentProps<typeof Kobalte.Head
|
||||
export interface AccordionTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {}
|
||||
export interface AccordionContentProps extends ComponentProps<typeof Kobalte.Content> {}
|
||||
|
||||
const AccordionItemContext = createContext<Accessor<boolean>>()
|
||||
|
||||
function AccordionRoot(props: AccordionProps) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
@@ -25,19 +22,17 @@ function AccordionRoot(props: AccordionProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem(props: AccordionItemProps & { expanded?: boolean }) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "expanded"])
|
||||
function AccordionItem(props: AccordionItemProps) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList"])
|
||||
return (
|
||||
<AccordionItemContext.Provider value={() => split.expanded ?? false}>
|
||||
<Kobalte.Item
|
||||
{...rest}
|
||||
data-slot="accordion-item"
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
/>
|
||||
</AccordionItemContext.Provider>
|
||||
<Kobalte.Item
|
||||
{...rest}
|
||||
data-slot="accordion-item"
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,25 +84,9 @@ function AccordionContent(props: ParentProps<AccordionContentProps>) {
|
||||
)
|
||||
}
|
||||
|
||||
export interface AccordionArrowProps extends ComponentProps<"div"> {
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
function AccordionArrow(props: AccordionArrowProps = {}) {
|
||||
const [local, rest] = splitProps(props, ["expanded"])
|
||||
const contextExpanded = useContext(AccordionItemContext)
|
||||
const isExpanded = () => local.expanded ?? contextExpanded?.() ?? false
|
||||
return (
|
||||
<div data-slot="accordion-arrow" {...rest}>
|
||||
<MorphChevron expanded={isExpanded()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Accordion = Object.assign(AccordionRoot, {
|
||||
Item: AccordionItem,
|
||||
Header: AccordionHeader,
|
||||
Trigger: AccordionTrigger,
|
||||
Content: AccordionContent,
|
||||
Arrow: AccordionArrow,
|
||||
})
|
||||
|
||||
@@ -8,13 +8,8 @@
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
padding: 4px 8px;
|
||||
white-space: nowrap;
|
||||
transition-property: background-color, border-color, color, box-shadow, opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
outline: none;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--button-primary-base);
|
||||
@@ -99,6 +94,7 @@
|
||||
&:active:not(:disabled) {
|
||||
background-color: var(--button-secondary-base);
|
||||
scale: 0.99;
|
||||
transition: all 150ms ease-out;
|
||||
}
|
||||
&:disabled {
|
||||
border-color: var(--border-disabled);
|
||||
@@ -113,27 +109,34 @@
|
||||
}
|
||||
|
||||
&[data-size="small"] {
|
||||
padding: 2px 8px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
&[data-icon] {
|
||||
padding: 2px 12px 2px 4px;
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
gap: 4px;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
&[data-size="normal"] {
|
||||
padding: 4px 6px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0 6px;
|
||||
&[data-icon] {
|
||||
padding: 4px 12px 4px 4px;
|
||||
padding: 0 12px 0 4px;
|
||||
}
|
||||
|
||||
font-size: var(--font-size-small);
|
||||
gap: 6px;
|
||||
|
||||
/* text-12-medium */
|
||||
@@ -145,10 +148,11 @@
|
||||
}
|
||||
|
||||
&[data-size="large"] {
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
|
||||
&[data-icon] {
|
||||
padding: 6px 12px 6px 8px;
|
||||
padding: 0 12px 0 8px;
|
||||
}
|
||||
|
||||
gap: 4px;
|
||||
@@ -158,6 +162,7 @@
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large); /* 142.857% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon"
|
||||
|
||||
export interface ButtonProps
|
||||
extends ComponentProps<typeof Kobalte>,
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children" | "style"> {
|
||||
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
|
||||
size?: "small" | "normal" | "large"
|
||||
variant?: "primary" | "secondary" | "ghost"
|
||||
icon?: IconProps["name"]
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
flex-direction: column;
|
||||
background-color: var(--surface-inset-base);
|
||||
border: 1px solid var(--border-weaker-base);
|
||||
transition-property: background-color, border-color;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: background-color 0.15s ease;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 12px;
|
||||
overflow: clip;
|
||||
|
||||
@@ -4,18 +4,6 @@
|
||||
gap: 12px;
|
||||
cursor: default;
|
||||
|
||||
[data-slot="checkbox-checkbox-control"] {
|
||||
transition-property: border-color, background-color, box-shadow;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
}
|
||||
|
||||
[data-slot="checkbox-checkbox-indicator"] {
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
}
|
||||
|
||||
[data-slot="checkbox-checkbox-input"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
flex-direction: column;
|
||||
background-color: var(--surface-inset-base);
|
||||
border: 1px solid var(--border-weaker-base);
|
||||
transition-property: background-color, border-color;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: background-color 0.15s ease;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: clip;
|
||||
|
||||
@@ -46,28 +44,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="collapsible-content"] {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition-property: grid-template-rows, opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
overflow: hidden;
|
||||
/* animation: slideUp 250ms ease-out; */
|
||||
|
||||
> * {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
&[data-closed] {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
/* &[data-expanded] { */
|
||||
/* animation: slideDown 250ms ease-out; */
|
||||
/* } */
|
||||
}
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
@@ -97,3 +83,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--kb-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--kb-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Collapsible as Kobalte, CollapsibleRootProps } from "@kobalte/core/collapsible"
|
||||
import { Accessor, ComponentProps, createContext, createSignal, ParentProps, splitProps, useContext } from "solid-js"
|
||||
import { MorphChevron } from "./morph-chevron"
|
||||
|
||||
const CollapsibleContext = createContext<Accessor<boolean>>()
|
||||
import { ComponentProps, ParentProps, splitProps } from "solid-js"
|
||||
import { Icon } from "./icon"
|
||||
|
||||
export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
|
||||
class?: string
|
||||
@@ -11,30 +9,17 @@ export interface CollapsibleProps extends ParentProps<CollapsibleRootProps> {
|
||||
}
|
||||
|
||||
function CollapsibleRoot(props: CollapsibleProps) {
|
||||
const [local, others] = splitProps(props, ["class", "classList", "variant", "open", "onOpenChange", "children"])
|
||||
const [internalOpen, setInternalOpen] = createSignal(local.open ?? false)
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setInternalOpen(open)
|
||||
local.onOpenChange?.(open)
|
||||
}
|
||||
|
||||
const [local, others] = splitProps(props, ["class", "classList", "variant"])
|
||||
return (
|
||||
<CollapsibleContext.Provider value={internalOpen}>
|
||||
<Kobalte
|
||||
data-component="collapsible"
|
||||
data-variant={local.variant || "normal"}
|
||||
open={local.open}
|
||||
onOpenChange={handleOpenChange}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
>
|
||||
{local.children}
|
||||
</Kobalte>
|
||||
</CollapsibleContext.Provider>
|
||||
<Kobalte
|
||||
data-component="collapsible"
|
||||
data-variant={local.variant || "normal"}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...others}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,10 +32,9 @@ function CollapsibleContent(props: ComponentProps<typeof Kobalte.Content>) {
|
||||
}
|
||||
|
||||
function CollapsibleArrow(props?: ComponentProps<"div">) {
|
||||
const isOpen = useContext(CollapsibleContext)
|
||||
return (
|
||||
<div data-slot="collapsible-arrow" {...(props || {})}>
|
||||
<MorphChevron expanded={isOpen?.() ?? false} />
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
.cycle-label {
|
||||
--c-dur: 200ms;
|
||||
--c-stag: 30ms;
|
||||
--c-ease: cubic-bezier(0.25, 0, 0.5, 1);
|
||||
--c-opacity-start: 0;
|
||||
--c-opacity-end: 1;
|
||||
--c-blur-start: 0px;
|
||||
--c-blur-end: 0px;
|
||||
--c-skew: 10deg;
|
||||
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
|
||||
transform-style: preserve-3d;
|
||||
perspective: 500px;
|
||||
transition: width 200ms var(--c-ease);
|
||||
will-change: width;
|
||||
overflow: hidden;
|
||||
|
||||
.cycle-char {
|
||||
display: inline-block;
|
||||
transform-style: preserve-3d;
|
||||
min-width: 0.25em;
|
||||
backface-visibility: hidden;
|
||||
|
||||
transition:
|
||||
transform var(--c-dur) var(--c-ease),
|
||||
opacity var(--c-dur) var(--c-ease),
|
||||
filter var(--c-dur) var(--c-ease);
|
||||
transition-delay: calc(var(--i, 0) * var(--c-stag));
|
||||
|
||||
&.enter {
|
||||
opacity: var(--c-opacity-end);
|
||||
filter: blur(var(--c-blur-end));
|
||||
transform: translateY(0) rotateX(0) skewX(0);
|
||||
}
|
||||
|
||||
&.exit {
|
||||
opacity: var(--c-opacity-start);
|
||||
filter: blur(var(--c-blur-start));
|
||||
transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew));
|
||||
}
|
||||
|
||||
&.pre {
|
||||
opacity: var(--c-opacity-start);
|
||||
filter: blur(var(--c-blur-start));
|
||||
transition: none;
|
||||
transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import "./cycle-label.css"
|
||||
import { createEffect, createSignal, JSX, on } from "solid-js"
|
||||
|
||||
export interface CycleLabelProps extends JSX.HTMLAttributes<HTMLSpanElement> {
|
||||
value: string
|
||||
onValueChange?: (value: string) => void
|
||||
duration?: number | ((value: string) => number)
|
||||
stagger?: number
|
||||
opacity?: [number, number]
|
||||
blur?: [number, number]
|
||||
skewX?: number
|
||||
onAnimationStart?: () => void
|
||||
onAnimationEnd?: () => void
|
||||
}
|
||||
|
||||
const segmenter =
|
||||
typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
|
||||
|
||||
const getChars = (text: string): string[] =>
|
||||
segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
export function CycleLabel(props: CycleLabelProps) {
|
||||
const getDuration = (text: string) => {
|
||||
const d = props?.duration ?? 200
|
||||
return typeof d === "function" ? d(text) : d
|
||||
}
|
||||
const stagger = () => props?.stagger ?? 20
|
||||
const opacity = () => props?.opacity ?? [0, 1]
|
||||
const blur = () => props?.blur ?? [0, 0]
|
||||
const skewX = () => props?.skewX ?? 10
|
||||
|
||||
let containerRef: HTMLSpanElement | undefined
|
||||
let isAnimating = false
|
||||
const [currentText, setCurrentText] = createSignal(props.value)
|
||||
|
||||
const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
|
||||
el.innerHTML = ""
|
||||
const chars = getChars(text)
|
||||
chars.forEach((char, i) => {
|
||||
const span = document.createElement("span")
|
||||
span.textContent = char === " " ? "\u00A0" : char
|
||||
span.className = `cycle-char ${state}`
|
||||
span.style.setProperty("--i", String(i))
|
||||
el.appendChild(span)
|
||||
})
|
||||
}
|
||||
|
||||
const animateToText = async (newText: string) => {
|
||||
if (!containerRef || isAnimating) return
|
||||
if (newText === currentText()) return
|
||||
|
||||
isAnimating = true
|
||||
props.onAnimationStart?.()
|
||||
|
||||
const dur = getDuration(newText)
|
||||
const stag = stagger()
|
||||
|
||||
containerRef.style.width = containerRef.offsetWidth + "px"
|
||||
|
||||
const oldChars = containerRef.querySelectorAll(".cycle-char")
|
||||
oldChars.forEach((c) => c.classList.replace("enter", "exit"))
|
||||
|
||||
const clone = containerRef.cloneNode(false) as HTMLElement
|
||||
Object.assign(clone.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
width: "auto",
|
||||
transition: "none",
|
||||
})
|
||||
setChars(clone, newText)
|
||||
document.body.appendChild(clone)
|
||||
const nextWidth = clone.offsetWidth
|
||||
clone.remove()
|
||||
|
||||
const exitTime = oldChars.length * stag + dur
|
||||
await wait(exitTime * 0.3)
|
||||
|
||||
containerRef.style.width = nextWidth + "px"
|
||||
|
||||
const widthDur = 200
|
||||
await wait(widthDur * 0.3)
|
||||
|
||||
setChars(containerRef, newText, "pre")
|
||||
containerRef.offsetWidth
|
||||
|
||||
Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
|
||||
setCurrentText(newText)
|
||||
props.onValueChange?.(newText)
|
||||
|
||||
const enterTime = getChars(newText).length * stag + dur
|
||||
await wait(enterTime)
|
||||
|
||||
containerRef.style.width = ""
|
||||
isAnimating = false
|
||||
props.onAnimationEnd?.()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
if (newValue !== currentText()) {
|
||||
animateToText(newValue)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const initRef = (el: HTMLSpanElement) => {
|
||||
containerRef = el
|
||||
setChars(el, props.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={initRef}
|
||||
class={`cycle-label ${props.class ?? ""}`}
|
||||
style={{
|
||||
"--c-dur": `${getDuration(currentText())}ms`,
|
||||
"--c-stag": `${stagger()}ms`,
|
||||
"--c-opacity-start": opacity()[0],
|
||||
"--c-opacity-end": opacity()[1],
|
||||
"--c-blur-start": `${blur()[0]}px`,
|
||||
"--c-blur-end": `${blur()[1]}px`,
|
||||
"--c-skew": `${skewX()}deg`,
|
||||
...(typeof props.style === "object" ? props.style : {}),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,16 +5,6 @@
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background-color: hsl(from var(--background-base) h s l / 0.2);
|
||||
|
||||
animation: overlayHide var(--transition-duration) var(--transition-easing) forwards;
|
||||
|
||||
&[data-expanded] {
|
||||
animation: overlayShow var(--transition-duration) var(--transition-easing) forwards;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dialog"] {
|
||||
@@ -35,6 +25,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
overflow: visible;
|
||||
|
||||
[data-slot="dialog-content"] {
|
||||
display: flex;
|
||||
@@ -44,8 +35,16 @@
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 280px;
|
||||
overflow: auto;
|
||||
pointer-events: auto;
|
||||
|
||||
/* Hide scrollbar */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* padding: 8px; */
|
||||
/* padding: 8px 8px 0 8px; */
|
||||
border-radius: var(--radius-xl);
|
||||
@@ -53,16 +52,6 @@
|
||||
background-clip: padding-box;
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
|
||||
animation: contentHide var(--transition-duration) var(--transition-easing) forwards;
|
||||
|
||||
&[data-expanded] {
|
||||
animation: contentShow var(--transition-duration) var(--transition-easing) forwards;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-slot="dialog-header"] {
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
@@ -173,7 +162,7 @@
|
||||
@keyframes contentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2.5%) scale(0.975);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -187,6 +176,6 @@
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-2.5%) scale(0.975);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,29 +2,26 @@
|
||||
[data-component="dropdown-menu-sub-content"] {
|
||||
min-width: 8rem;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 50;
|
||||
transform-origin: var(--kb-menu-content-transform-origin);
|
||||
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
|
||||
|
||||
@starting-style {
|
||||
animation: none;
|
||||
&[data-closed] {
|
||||
animation: dropdown-menu-close 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
pointer-events: auto;
|
||||
animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
|
||||
animation: dropdown-menu-open 0.15s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,22 +38,18 @@
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
outline: none;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
transition-property: background-color, color;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
&[data-highlighted] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
@@ -68,8 +61,6 @@
|
||||
[data-slot="dropdown-menu-sub-trigger"] {
|
||||
&[data-expanded] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,24 +102,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownMenuContentShow {
|
||||
@keyframes dropdown-menu-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdownMenuContentHide {
|
||||
@keyframes dropdown-menu-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
}
|
||||
|
||||
&[data-closed] {
|
||||
animation: hover-card-close var(--transition-duration) var(--transition-easing);
|
||||
animation: hover-card-close 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
animation: hover-card-open var(--transition-duration) var(--transition-easing);
|
||||
animation: hover-card-open 0.15s ease-out;
|
||||
}
|
||||
|
||||
[data-slot="hover-card-body"] {
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
user-select: none;
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
transition-property: background-color, color, opacity, box-shadow;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
|
||||
&[data-variant="primary"] {
|
||||
background-color: var(--icon-strong-base);
|
||||
@@ -102,7 +99,7 @@
|
||||
/* color: var(--icon-active); */
|
||||
/* } */
|
||||
}
|
||||
&[data-selected]:not(:disabled) {
|
||||
&:selected:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
/* [data-slot="icon-svg"] { */
|
||||
/* color: var(--icon-selected); */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
/* resize: both; */
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 1/1;
|
||||
color: var(--icon-base);
|
||||
|
||||
&[data-size="small"] {
|
||||
|
||||
@@ -80,16 +80,13 @@ const icons = {
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
name: keyof typeof icons
|
||||
size?: "small" | "normal" | "medium" | "large" | number
|
||||
size?: "small" | "normal" | "medium" | "large"
|
||||
}
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
|
||||
return (
|
||||
<div
|
||||
data-component="icon"
|
||||
data-size={typeof local.size !== "number" ? local.size || "normal" : `size-[${local.size}px]`}
|
||||
>
|
||||
<div data-component="icon" data-size={local.size || "normal"}>
|
||||
<svg
|
||||
data-slot="icon-svg"
|
||||
classList={{
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
[data-component="list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
padding: 0 12px;
|
||||
|
||||
@@ -37,9 +37,7 @@
|
||||
flex-shrink: 0;
|
||||
background-color: transparent;
|
||||
opacity: 0.5;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled),
|
||||
@@ -90,9 +88,7 @@
|
||||
height: 20px;
|
||||
background-color: transparent;
|
||||
opacity: 0.5;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled),
|
||||
@@ -135,6 +131,15 @@
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
|
||||
animation: scroll;
|
||||
animation-timeline: --scroll;
|
||||
scroll-timeline: --scroll y;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="list-empty-state"] {
|
||||
display: flex;
|
||||
@@ -210,9 +215,7 @@
|
||||
background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-easing);
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
&[data-stuck="true"]::after {
|
||||
@@ -248,22 +251,17 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 1/1;
|
||||
[data-component="icon"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
}
|
||||
|
||||
[name="check"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
[data-slot="list-item-active-icon"] {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 1/1;
|
||||
[data-component="icon"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useI18n } from "../context/i18n"
|
||||
import { Icon, type IconProps } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { TextField } from "./text-field"
|
||||
import { ScrollFade } from "./scroll-fade"
|
||||
|
||||
function findByKey(container: HTMLElement, key: string) {
|
||||
const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]')
|
||||
@@ -268,7 +267,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
{searchAction()}
|
||||
</div>
|
||||
</Show>
|
||||
<ScrollFade ref={setScrollRef} direction="vertical" fadeStartSize={0} fadeEndSize={20} data-slot="list-scroll">
|
||||
<div ref={setScrollRef} data-slot="list-scroll">
|
||||
<Show
|
||||
when={flat().length > 0 || showAdd()}
|
||||
fallback={
|
||||
@@ -340,7 +339,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</ScrollFade>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[data-component="logo-mark"] {
|
||||
width: 16px;
|
||||
aspect-ratio: 4 / 5;
|
||||
aspect-ratio: 4/5;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ import { Tooltip } from "./tooltip"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { createAutoScroll } from "../hooks"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { MorphChevron } from "./morph-chevron"
|
||||
|
||||
interface Diagnostic {
|
||||
range: {
|
||||
@@ -416,7 +415,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
toggleExpanded()
|
||||
}}
|
||||
>
|
||||
<MorphChevron expanded={expanded()} />
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
<div data-slot="user-message-copy-wrapper">
|
||||
<Tooltip
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
[data-slot="morph-chevron-svg"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke: currentcolor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user