mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-14 04:46:23 +00:00
Compare commits
103
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17f6cd601e | ||
|
|
aeed4b6375 | ||
|
|
82f713421f | ||
|
|
763cac8080 | ||
|
|
c2cf497e19 | ||
|
|
7784b3ee0d | ||
|
|
199aabe9e2 | ||
|
|
8905af5074 | ||
|
|
ce56111a4c | ||
|
|
5ab0167288 | ||
|
|
487e1bd76e | ||
|
|
1eeaaab7a6 | ||
|
|
b901cb28af | ||
|
|
13453f2da8 | ||
|
|
e16c56fe8b | ||
|
|
42ff564913 | ||
|
|
195158c34c | ||
|
|
7a31b5c0f7 | ||
|
|
fb3c10ca66 | ||
|
|
c43cfccc4e | ||
|
|
c5aa7d7e34 | ||
|
|
9c8a4ea4ff | ||
|
|
c82340a97b | ||
|
|
dbc63955b0 | ||
|
|
2816d1c849 | ||
|
|
61d812fcd6 | ||
|
|
e47b9b5453 | ||
|
|
21dac524f3 | ||
|
|
3dde11c392 | ||
|
|
0643a5638e | ||
|
|
81523d4a84 | ||
|
|
7c5a4d01aa | ||
|
|
4143084250 | ||
|
|
c7868ce0e6 | ||
|
|
cf4f1fb45e | ||
|
|
0a21042523 | ||
|
|
6add96698e | ||
|
|
3f579f53f4 | ||
|
|
9f3ba44c4a | ||
|
|
c4fe0f676a | ||
|
|
625c469854 | ||
|
|
d6c22b3bf5 | ||
|
|
bbf4cd4975 | ||
|
|
27027777d2 | ||
|
|
5d1841e0aa | ||
|
|
2253d4c31d | ||
|
|
877b04f0b9 | ||
|
|
41e5d1b6b6 | ||
|
|
60fb97d59e | ||
|
|
b2e6e764a8 | ||
|
|
0d8e64112f | ||
|
|
b0b85cbb77 | ||
|
|
0f26ad8787 | ||
|
|
b08ad82518 | ||
|
|
5319f063d6 | ||
|
|
63f7ceecbe | ||
|
|
770420bdad | ||
|
|
4994a7d694 | ||
|
|
c55ee2a815 | ||
|
|
312651f68e | ||
|
|
654f411a5b | ||
|
|
68b862da49 | ||
|
|
8f2b13e864 | ||
|
|
bb564f96a3 | ||
|
|
a555f24ded | ||
|
|
10ae321bf3 | ||
|
|
6e8ff0c2b7 | ||
|
|
923bda07a2 | ||
|
|
4c34ee5eab | ||
|
|
2df00955cb | ||
|
|
e0deffa083 | ||
|
|
4e97b78d98 | ||
|
|
6bb4b35399 | ||
|
|
0ce1383030 | ||
|
|
010cd6131e | ||
|
|
c2348f8f69 | ||
|
|
71317ec7c9 | ||
|
|
4261fe749d | ||
|
|
9dd7149e75 | ||
|
|
0c0a431c9f | ||
|
|
3368e049d2 | ||
|
|
f0b5da1c11 | ||
|
|
15c525dcfb | ||
|
|
4d12e01824 | ||
|
|
70afbac80c | ||
|
|
1c723c56fa | ||
|
|
181428a2f3 | ||
|
|
9b1891fb7e | ||
|
|
d4bf78b348 | ||
|
|
d4ceffe787 | ||
|
|
872e38055e | ||
|
|
0c1dfa9186 | ||
|
|
8f4d706647 | ||
|
|
929374cdfd | ||
|
|
cfa5ba700e | ||
|
|
45a2ed9a97 | ||
|
|
f6333546f8 | ||
|
|
9e153ce7b3 | ||
|
|
eb357f17cf | ||
|
|
573d76933f | ||
|
|
bb8194395a | ||
|
|
2e8ed86658 | ||
|
|
2695607fbc |
@@ -1,4 +1,4 @@
|
||||
name: typecheck
|
||||
name: check
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
check:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -17,5 +17,5 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run typecheck
|
||||
run: bun typecheck
|
||||
- name: Run checks
|
||||
run: bun run check
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -33,7 +33,7 @@ permissions:
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && !inputs.bump && !inputs.version && 'dev') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
fi
|
||||
|
||||
found=0
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -276,10 +276,9 @@ jobs:
|
||||
|
||||
sign-cli-windows:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
- sign-cli-macos
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'v2' || github.ref_name == 'beta')
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -292,15 +291,8 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
@@ -315,9 +307,9 @@ jobs:
|
||||
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
files: |
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe
|
||||
exclude-environment-credential: true
|
||||
exclude-workload-identity-credential: true
|
||||
exclude-managed-identity-credential: true
|
||||
@@ -333,9 +325,9 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe"
|
||||
)
|
||||
|
||||
foreach ($file in $files) {
|
||||
@@ -345,40 +337,17 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
- name: Repack Windows CLI archives
|
||||
working-directory: packages/opencode/dist
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
|
||||
|
||||
- name: Upload signed Windows CLI release assets
|
||||
if: needs.version.outputs.release != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
gh release upload "v${{ needs.version.outputs.version }}" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
|
||||
--clobber `
|
||||
--repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: |
|
||||
packages/opencode/dist/opencode-windows-arm64
|
||||
packages/opencode/dist/opencode-windows-x64
|
||||
packages/opencode/dist/opencode-windows-x64-baseline
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- version
|
||||
- sign-cli-macos
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
- sign-cli-windows
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name != 'v2' || needs.version.outputs.release != '')
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
+1
-1
@@ -17,4 +17,4 @@ if (process.versions.bun !== expectedBunVersion) {
|
||||
console.warn(`Warning: Bun version ${process.versions.bun} differs from expected ${expectedBunVersion}`);
|
||||
}
|
||||
'
|
||||
bun typecheck
|
||||
bun run check
|
||||
|
||||
+14
-37
@@ -1,45 +1,22 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"categories": {
|
||||
"suspicious": "warn"
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"style": "off",
|
||||
"restriction": "off",
|
||||
"nursery": "off"
|
||||
},
|
||||
"rules": {
|
||||
"typescript/no-base-to-string": "warn",
|
||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
||||
"require-yield": "off",
|
||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
||||
"no-unassigned-vars": "off",
|
||||
// SolidJS tracks reactive deps by reading properties inside createEffect
|
||||
"no-unused-expressions": "off",
|
||||
// Intentional control char matching (ANSI escapes, null byte sanitization)
|
||||
"no-control-regex": "off",
|
||||
// SST and plugin tools require triple-slash references
|
||||
"triple-slash-reference": "off",
|
||||
|
||||
// Suspicious category: suppress noisy rules
|
||||
// Effect's nested function* closures inherently shadow outer scope
|
||||
"no-shadow": "off",
|
||||
// Namespace-heavy codebase makes this too noisy
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
|
||||
"unicorn/no-array-sort": "off",
|
||||
"unicorn/no-array-reverse": "off",
|
||||
// Not relevant — this isn't a DOM event handler codebase
|
||||
"unicorn/prefer-add-event-listener": "off",
|
||||
// Bundler handles module resolution
|
||||
"unicorn/require-module-specifiers": "off",
|
||||
// postMessage target origin not relevant for this codebase
|
||||
"unicorn/require-post-message-target-origin": "off",
|
||||
// Side-effectful constructors are intentional in some places
|
||||
"no-new": "off",
|
||||
|
||||
// Type-aware: catch unhandled promises
|
||||
"typescript/no-floating-promises": "warn",
|
||||
// Warn when spreading non-plain objects (Headers, class instances, etc.)
|
||||
"typescript/no-misused-spread": "warn"
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
"name": "Reflect",
|
||||
"message": "Use typed property access or direct invocation. Suppress this rule only for genuine reflection."
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
|
||||
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## V2 TUI Stories
|
||||
@@ -170,9 +170,10 @@ const table = sqliteTable("session", {
|
||||
- Test actual implementation, do not duplicate logic into tests
|
||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
|
||||
|
||||
## Type Checking
|
||||
## Checks
|
||||
|
||||
- Always run `bun typecheck` from package directories (for example, `packages/core`), never `tsc` directly.
|
||||
- Run `bun run check` from the repository root as the canonical full lint and type-check verification.
|
||||
- During focused iteration, run `bun typecheck` from the affected package directory (for example, `packages/core`). Never run `tsc` directly.
|
||||
|
||||
## V2 Session Core
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# V2 HTTP API audit checklist
|
||||
|
||||
**Source:** `packages/protocol/openapi.json`
|
||||
**Current endpoint count:** 139
|
||||
**Last regenerated:** 2026-09-13
|
||||
|
||||
## How to use this checklist
|
||||
|
||||
Review endpoints in document order. For each endpoint, select one disposition and capture rationale or follow-up work in Notes. Mark **Reviewed** only after the disposition is agreed.
|
||||
|
||||
### Review criteria
|
||||
|
||||
- Resource and operation naming
|
||||
- HTTP method and idempotency
|
||||
- Request parameters and location scope
|
||||
- Response shape and error taxonomy
|
||||
- Authentication and authorization
|
||||
- Current production consumers
|
||||
- Stability level: public, experimental, or internal
|
||||
- Whether the generated client API is intuitive
|
||||
|
||||
### Disposition legend
|
||||
|
||||
- **Keep:** ship unchanged as a supported V2 API
|
||||
- **Change:** retain after a defined contract change
|
||||
- **Remove:** exclude from the official V2 API
|
||||
- **Experimental-only:** retain outside the stable API commitment
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Group 1: Foundation and placement (4)
|
||||
- [ ] Group 2: Configuration and capability catalogs (16)
|
||||
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
|
||||
- [ ] Group 4: Session lifecycle (12)
|
||||
- [ ] Group 5: Session execution and inputs (11)
|
||||
- [ ] Group 6: Session history and recovery (13)
|
||||
- [ ] Group 7: Inbox, permissions, and forms (19)
|
||||
- [ ] Group 8: Filesystem, worktrees, and VCS (12)
|
||||
- [ ] Group 9: PTYs, persistent terminals, and shells (24)
|
||||
- [ ] Group 10: Events, RPC, and experimental operations (6)
|
||||
|
||||
## Resolved during audit
|
||||
|
||||
### [x] `POST /api/plugin/await-activation`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Notes:** Activation timing is an internal server concern. Catalog reads remain non-blocking.
|
||||
|
||||
### [x] Location response wrappers
|
||||
|
||||
- **Decision:** Reduce generic endpoint response locations to `{ directory }`.
|
||||
- **Notes:** Full project metadata remains available from `GET /api/location`; no consumers used it from wrapped responses.
|
||||
|
||||
### [x] `GET /api/health` and `GET /api/server`
|
||||
|
||||
- **Decision:** Merge and rename
|
||||
- **Replacement:** `GET /api/status` with operation ID `server.status`.
|
||||
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
|
||||
|
||||
### [x] `GET /api/project/current`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Replacement:** `GET /api/location`, using `project` from the response.
|
||||
- **Notes:** The endpoint duplicated `Location.Info.project`; production callers were migrated.
|
||||
|
||||
### [x] `POST /api/workspace` and `DELETE /api/workspace/{workspaceID}`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Notes:** Provider-backed workspaces are not part of the V2 HTTP contract and can be introduced later. Core and the embedded SDK retain internal workspace support.
|
||||
|
||||
## Group 1: Foundation and placement
|
||||
|
||||
**Endpoints:** 4
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 001–002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
|
||||
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
|
||||
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
|
||||
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
|
||||
|
||||
## Group 2: Configuration and capability catalogs
|
||||
|
||||
**Endpoints:** 16
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 008 | `GET` | `/api/agent` | `agent.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 010 | `GET` | `/api/plugin` | `plugin.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 012 | `POST` | `/api/plugin/check` | `plugin.check` | Keep | Request and response accepted as-is. |
|
||||
| [x] 013 | `POST` | `/api/plugin/update` | `plugin.update` | Keep | Request and errors accepted as-is. |
|
||||
| [x] 014 | `GET` | `/api/model` | `model.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 015 | `GET` | `/api/model/default` | `model.default` | Keep | Request and nullable response accepted as-is. |
|
||||
| [x] 016 | `GET` | `/api/provider` | `provider.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | Keep | Request, response, and not-found error accepted as-is. |
|
||||
| [x] 018 | `GET` | `/api/command` | `command.list` | Keep | Request and response accepted as-is. |
|
||||
| [x] 019 | `GET` | `/api/skill` | `skill.list` | Keep | Renamed `location` to `path`; removed the skill-specific `slash` flag and slash-command behavior. |
|
||||
| [x] 020 | `GET` | `/api/reference` | `reference.list` | Keep | Removed duplicate `description` and `hidden` fields from nested `source`. |
|
||||
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
|
||||
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
|
||||
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
|
||||
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
|
||||
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
|
||||
|
||||
## Group 3: Credentials, integrations, MCP, and web search
|
||||
|
||||
**Endpoints:** 22
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
|
||||
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
|
||||
| [ ] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | | |
|
||||
| [ ] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | | |
|
||||
| [ ] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | | |
|
||||
| [ ] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | | |
|
||||
| [ ] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | | |
|
||||
| [ ] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | | |
|
||||
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
|
||||
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
|
||||
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
|
||||
| [ ] 036 | `GET` | `/api/mcp` | `mcp.list` | | |
|
||||
| [ ] 037 | `PUT` | `/api/mcp/{server}` | `mcp.add` | | |
|
||||
| [ ] 038 | `DELETE` | `/api/mcp/{server}` | `mcp.remove` | | |
|
||||
| [ ] 039 | `POST` | `/api/mcp/{server}/connect` | `mcp.connect` | | |
|
||||
| [ ] 040 | `POST` | `/api/mcp/{server}/disconnect` | `mcp.disconnect` | | |
|
||||
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | |
|
||||
| [ ] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | | |
|
||||
| [ ] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | | |
|
||||
| [ ] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | | |
|
||||
| [ ] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | | |
|
||||
| [ ] 046 | `POST` | `/api/websearch` | `websearch.query` | | |
|
||||
|
||||
## Group 4: Session lifecycle
|
||||
|
||||
**Endpoints:** 12
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 047 | `GET` | `/api/session` | `session.list` | | |
|
||||
| [ ] 048 | `POST` | `/api/session` | `session.create` | | |
|
||||
| [ ] 049 | `GET` | `/api/session/stats` | `session.stats` | | |
|
||||
| [ ] 050 | `GET` | `/api/session/active` | `session.active` | | |
|
||||
| [ ] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | | |
|
||||
| [ ] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | | |
|
||||
| [ ] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | | |
|
||||
| [ ] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | | |
|
||||
| [ ] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | | |
|
||||
| [ ] 056 | `POST` | `/api/session/{sessionID}/rename` | `session.rename` | | |
|
||||
| [ ] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | | |
|
||||
| [ ] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | | |
|
||||
|
||||
## Group 5: Session execution and inputs
|
||||
|
||||
**Endpoints:** 11
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 059 | `POST` | `/api/session/{sessionID}/prompt` | `session.prompt` | | |
|
||||
| [ ] 060 | `POST` | `/api/session/{sessionID}/command` | `session.command` | | |
|
||||
| [ ] 061 | `POST` | `/api/session/{sessionID}/skill` | `session.skill` | | |
|
||||
| [ ] 062 | `POST` | `/api/session/{sessionID}/synthetic` | `session.synthetic` | | |
|
||||
| [ ] 063 | `POST` | `/api/session/{sessionID}/shell` | `session.shell` | | |
|
||||
| [ ] 064 | `POST` | `/api/session/{sessionID}/compact` | `session.compact` | | |
|
||||
| [ ] 065 | `POST` | `/api/session/{sessionID}/wait` | `session.wait` | | |
|
||||
| [ ] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | | |
|
||||
| [ ] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | | |
|
||||
| [ ] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | | |
|
||||
| [ ] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | | |
|
||||
|
||||
## Group 6: Session history and recovery
|
||||
|
||||
**Endpoints:** 13
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 070 | `POST` | `/api/session/import` | `session.import` | | |
|
||||
| [ ] 071 | `GET` | `/api/session/{sessionID}/export` | `session.export` | | |
|
||||
| [ ] 072 | `POST` | `/api/session/{sessionID}/revert/stage` | `session.revert.stage` | | |
|
||||
| [ ] 073 | `POST` | `/api/session/{sessionID}/revert/clear` | `session.revert.clear` | | |
|
||||
| [ ] 074 | `POST` | `/api/session/{sessionID}/revert/commit` | `session.revert.commit` | | |
|
||||
| [ ] 075 | `GET` | `/api/session/{sessionID}/context` | `session.context` | | |
|
||||
| [ ] 076 | `GET` | `/api/session/{sessionID}/diff` | `session.diff` | | |
|
||||
| [ ] 077 | `GET` | `/api/session/{sessionID}/instructions/entries` | `session.instructions.entry.list` | | |
|
||||
| [ ] 078 | `PUT` | `/api/session/{sessionID}/instructions/entries/{key}` | `session.instructions.entry.put` | | |
|
||||
| [ ] 079 | `DELETE` | `/api/session/{sessionID}/instructions/entries/{key}` | `session.instructions.entry.remove` | | |
|
||||
| [ ] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | | |
|
||||
| [ ] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message` | | |
|
||||
| [ ] 082 | `GET` | `/api/session/{sessionID}/message` | `message.list` | | |
|
||||
|
||||
## Group 7: Inbox, permissions, and forms
|
||||
|
||||
**Endpoints:** 19
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | | |
|
||||
| [ ] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | | |
|
||||
| [ ] 085 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/steer` | `session.inbox.steer` | | |
|
||||
| [ ] 086 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/queue` | `session.inbox.queue` | | |
|
||||
| [ ] 087 | `GET` | `/api/form/request` | `form.request.list` | | |
|
||||
| [ ] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | | |
|
||||
| [ ] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | | |
|
||||
| [ ] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | | |
|
||||
| [ ] 091 | `GET` | `/api/session/{sessionID}/form/{formID}/state` | `session.form.state` | | |
|
||||
| [ ] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | | |
|
||||
| [ ] 093 | `POST` | `/api/session/{sessionID}/form/{formID}/cancel` | `session.form.cancel` | | |
|
||||
| [ ] 094 | `GET` | `/api/permission/request` | `permission.request.list` | | |
|
||||
| [ ] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | | |
|
||||
| [ ] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | | |
|
||||
| [ ] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | | |
|
||||
| [ ] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | | |
|
||||
| [ ] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | | |
|
||||
| [ ] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | | |
|
||||
| [ ] 101 | `PUT` | `/api/session/{sessionID}/permission/rules` | `session.permission.rules` | | |
|
||||
|
||||
## Group 8: Filesystem, worktrees, and VCS
|
||||
|
||||
**Endpoints:** 12
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 102 | `GET` | `/api/fs/read/*` | `fs.read` | | |
|
||||
| [ ] 103 | `GET` | `/api/fs/list` | `fs.list` | | |
|
||||
| [ ] 104 | `GET` | `/api/fs/find` | `fs.find` | | |
|
||||
| [ ] 105 | `GET` | `/api/worktree` | `worktree.list` | | |
|
||||
| [ ] 106 | `POST` | `/api/worktree` | `worktree.create` | | |
|
||||
| [ ] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | | |
|
||||
| [ ] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | | |
|
||||
| [ ] 109 | `GET` | `/api/vcs` | `vcs.get` | | |
|
||||
| [ ] 110 | `GET` | `/api/vcs/base` | `vcs.base` | | |
|
||||
| [ ] 111 | `GET` | `/api/vcs/status` | `vcs.status` | | |
|
||||
| [ ] 112 | `GET` | `/api/vcs/branches` | `vcs.branches` | | |
|
||||
| [ ] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | | |
|
||||
|
||||
## Group 9: PTYs, persistent terminals, and shells
|
||||
|
||||
**Endpoints:** 24
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 114 | `GET` | `/api/pty` | `pty.list` | | |
|
||||
| [ ] 115 | `POST` | `/api/pty` | `pty.create` | | |
|
||||
| [ ] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | | |
|
||||
| [ ] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | | |
|
||||
| [ ] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | | |
|
||||
| [ ] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | | |
|
||||
| [ ] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | | |
|
||||
| [ ] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | | |
|
||||
| [ ] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | | |
|
||||
| [ ] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | | |
|
||||
| [ ] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | | |
|
||||
| [ ] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | | |
|
||||
| [ ] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | | |
|
||||
| [ ] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | | |
|
||||
| [ ] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | | |
|
||||
| [ ] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | | |
|
||||
| [ ] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | | |
|
||||
| [ ] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | | |
|
||||
| [ ] 132 | `GET` | `/api/shell` | `shell.list` | | |
|
||||
| [ ] 133 | `POST` | `/api/shell` | `shell.create` | | |
|
||||
| [ ] 134 | `GET` | `/api/shell/{id}` | `shell.get` | | |
|
||||
| [ ] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | | |
|
||||
| [ ] 136 | `PATCH` | `/api/shell/{id}/timeout` | `shell.timeout` | | |
|
||||
| [ ] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | | |
|
||||
|
||||
## Group 10: Events, RPC, and experimental operations
|
||||
|
||||
**Endpoints:** 6
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 138 | `POST` | `/api/generate` | `generate.text` | | |
|
||||
| [ ] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | | |
|
||||
| [ ] 140 | `GET` | `/api/event` | `event.subscribe` | | |
|
||||
| [ ] 141 | `GET` | `/api/debug/location` | `debug.location.list` | | |
|
||||
| [ ] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | | |
|
||||
| [ ] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | | |
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "1.17.20",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -82,6 +82,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"core-js": "3.50.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
@@ -111,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.3",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +176,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "1.17.13",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,7 +202,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -215,7 +216,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -251,7 +252,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -278,7 +279,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -295,7 +296,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -319,7 +320,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -339,7 +340,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
@@ -411,7 +412,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -463,7 +464,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -500,7 +501,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -516,7 +517,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -535,7 +536,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -548,7 +549,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -562,7 +563,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -577,7 +578,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -616,7 +617,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -646,7 +647,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "1.17.11",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -661,7 +662,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "1.17.11",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -685,7 +686,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -706,7 +707,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -728,7 +729,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -763,7 +764,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "1.17.13",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -783,7 +784,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -817,7 +818,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -836,7 +837,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -882,7 +883,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -896,7 +897,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -931,7 +932,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -966,7 +967,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "1.18.3",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -999,7 +1000,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1040,7 +1041,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3702,6 +3703,8 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
|
||||
|
||||
"core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APP=opencode2
|
||||
APP=opencode
|
||||
SOURCE_APP=opencode
|
||||
|
||||
MUTED='\033[0;2m'
|
||||
RED='\033[0;31m'
|
||||
@@ -22,7 +23,7 @@ Options:
|
||||
Examples:
|
||||
curl -fsSL https://opencode.ai/v2/install | bash
|
||||
curl -fsSL https://opencode.ai/v2/install | bash -s -- --version 0.0.0-beta-17236
|
||||
./install --binary /path/to/opencode2
|
||||
./install --binary /path/to/opencode
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -201,9 +202,9 @@ else
|
||||
|
||||
filename="cli-$target-$specific_version.tgz"
|
||||
url="https://registry.npmjs.org/$package_name/-/$filename"
|
||||
binary_name="$APP"
|
||||
binary_name="$SOURCE_APP"
|
||||
if [ "$os" = "windows" ]; then
|
||||
binary_name="$APP.exe"
|
||||
binary_name="$SOURCE_APP.exe"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -230,12 +231,7 @@ check_version() {
|
||||
installed_version="${installed_version##* }"
|
||||
installed_version="${installed_version#v}"
|
||||
|
||||
if [[ "$installed_version" != "$specific_version" ]]; then
|
||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||
else
|
||||
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
|
||||
exit 0
|
||||
fi
|
||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -340,15 +336,46 @@ download_and_install() {
|
||||
fi
|
||||
|
||||
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
|
||||
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR"
|
||||
chmod 755 "${INSTALL_DIR}/$binary_name"
|
||||
local installed_binary="$APP"
|
||||
if [ "$os" = "windows" ]; then
|
||||
installed_binary="$APP.exe"
|
||||
fi
|
||||
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR/$installed_binary"
|
||||
chmod 755 "$INSTALL_DIR/$installed_binary"
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
install_from_binary() {
|
||||
print_message info "\n${MUTED}Installing ${NC}$APP ${MUTED}from: ${NC}$binary_path"
|
||||
cp "$binary_path" "${INSTALL_DIR}/$APP"
|
||||
chmod 755 "${INSTALL_DIR}/$APP"
|
||||
local installed_binary="$APP"
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) installed_binary="$APP.exe" ;;
|
||||
esac
|
||||
cp "$binary_path" "$INSTALL_DIR/$installed_binary"
|
||||
chmod 755 "$INSTALL_DIR/$installed_binary"
|
||||
}
|
||||
|
||||
install_legacy_shim() {
|
||||
local shim_os="${os:-}"
|
||||
if [[ -z "$shim_os" ]]; then
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) shim_os="windows" ;;
|
||||
esac
|
||||
fi
|
||||
rm -f "$INSTALL_DIR/opencode2" "$INSTALL_DIR/opencode2.exe" "$INSTALL_DIR/opencode2.cmd"
|
||||
if [[ "$shim_os" == "windows" ]]; then
|
||||
cat > "$INSTALL_DIR/opencode2.cmd" <<'EOF'
|
||||
@echo off
|
||||
"%~dp0opencode.exe" %*
|
||||
exit /b %errorlevel%
|
||||
EOF
|
||||
return
|
||||
fi
|
||||
cat > "$INSTALL_DIR/opencode2" <<'EOF'
|
||||
#!/bin/sh
|
||||
exec "$(dirname "$0")/opencode" "$@"
|
||||
EOF
|
||||
chmod 755 "$INSTALL_DIR/opencode2"
|
||||
}
|
||||
|
||||
if [ -n "$binary_path" ]; then
|
||||
@@ -357,6 +384,7 @@ else
|
||||
check_version
|
||||
download_and_install
|
||||
fi
|
||||
install_legacy_shim
|
||||
|
||||
|
||||
add_to_path() {
|
||||
@@ -453,7 +481,7 @@ echo -e ""
|
||||
echo -e "${MUTED}OpenCode includes free models, to start:${NC}"
|
||||
echo -e ""
|
||||
echo -e "cd <project> ${MUTED}# Open directory${NC}"
|
||||
echo -e "opencode2 ${MUTED}# Run command${NC}"
|
||||
echo -e "opencode ${MUTED}# Run command${NC}"
|
||||
echo -e ""
|
||||
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/v2/docs"
|
||||
echo -e ""
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-yzCk746pospz8EVakHRcDhYJkhGYGSt9dHOPbzO4OYo=",
|
||||
"aarch64-linux": "sha256-MFJVLos4v2r9jazmmr3ldVCJKLrO+Qp/BpGpyM/1lf8=",
|
||||
"aarch64-darwin": "sha256-k8r/HVSgdRSTlJ1lI7EebqbxeA3AElnaw1sDYOPEdQw=",
|
||||
"x86_64-darwin": "sha256-ACJdJfz12xLBQvWIkbuve86znSoGJ2PLDQbgh0cT6/g="
|
||||
"x86_64-linux": "sha256-euVUyj0CzjCA1nYbN2vKctEPzLkUlNGTK2dMNbackqM=",
|
||||
"aarch64-linux": "sha256-qQkjqaxpjAae+rohoWI601QnrgKYghJ+ttqeiQBTwCM=",
|
||||
"aarch64-darwin": "sha256-HYWs31TJlDZsDBNmbPARo16r7zNKy9x840uHGcUMYsk=",
|
||||
"x86_64-darwin": "sha256-89FOrX813FENk3u8RAHCfyD7voaZWW++Z4Gpa3SkOJs="
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -2,15 +2,15 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev \"$@\" --server \"$(opencode service status)\"' --",
|
||||
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
@@ -24,6 +24,7 @@
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
"test:effect-simplification-rules": "ast-grep test -c script/ast-grep/effect-simplifications/sgconfig.yml",
|
||||
"typecheck": "bun turbo typecheck --concurrency=3",
|
||||
"check": "bun run lint && bun run typecheck",
|
||||
"typecheck:profile": "bun script/profile-typecheck.ts",
|
||||
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
|
||||
@@ -122,13 +122,14 @@ Keep provider facades small and explicit:
|
||||
|
||||
### Provider Package Entrypoints
|
||||
|
||||
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.20",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1708,7 +1708,10 @@ export const transport = <
|
||||
}
|
||||
|
||||
function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">) {
|
||||
const betas: string[] = []
|
||||
// Always request interleaved thinking. The API accepts the header on any
|
||||
// model and ignores it where unsupported, while manual-thinking models need
|
||||
// it for thinking between tool calls.
|
||||
const betas: string[] = ["interleaved-thinking-2025-05-14"]
|
||||
const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0
|
||||
const replaysCompaction = body.messages.some((message) =>
|
||||
message.content.some((block) => block.type === "compaction"),
|
||||
|
||||
@@ -25,6 +25,7 @@ import { BedrockAuth } from "./utils/bedrock-auth.js"
|
||||
import { BedrockCache } from "./utils/bedrock-cache.js"
|
||||
import { BedrockMedia } from "./utils/bedrock-media.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
@@ -106,7 +107,7 @@ type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
|
||||
const BedrockToolSpec = Schema.Struct({
|
||||
toolSpec: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
inputSchema: Schema.Struct({
|
||||
json: JsonObject,
|
||||
}),
|
||||
@@ -222,7 +223,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
|
||||
inputSchema: { json: inputSchema },
|
||||
},
|
||||
})
|
||||
@@ -279,15 +280,19 @@ const removeEmptyToolInputKeys = (input: unknown): unknown => {
|
||||
)
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: part.id,
|
||||
name: part.name,
|
||||
toolUseId: normalizeID(part.id),
|
||||
// Models can emit names that Converse rejects when replayed in history.
|
||||
name: part.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_",
|
||||
input: removeEmptyToolInputKeys(part.input),
|
||||
},
|
||||
})
|
||||
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
) {
|
||||
if (part.result.type === "text" || part.result.type === "error")
|
||||
return [{ text: ProviderShared.toolResultText(part) }]
|
||||
if (part.result.type === "json") return [{ json: part.result.value }]
|
||||
@@ -298,22 +303,29 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
|
||||
content.push({ text: item.text })
|
||||
continue
|
||||
}
|
||||
const media = yield* BedrockMedia.lower({
|
||||
type: "media",
|
||||
mediaType: item.mime,
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
})
|
||||
content.push(media)
|
||||
const media = yield* BedrockMedia.lower(
|
||||
{
|
||||
type: "media",
|
||||
mediaType: item.mime,
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
},
|
||||
documentNames,
|
||||
)
|
||||
content.push(...media)
|
||||
}
|
||||
return content
|
||||
})
|
||||
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
normalizeID: (id: string) => string,
|
||||
) {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: part.id,
|
||||
content: yield* lowerToolResultContent(part),
|
||||
toolUseId: normalizeID(part.id),
|
||||
content: yield* lowerToolResultContent(part, documentNames),
|
||||
status: part.result.type === "error" ? "error" : "success",
|
||||
},
|
||||
} satisfies BedrockToolResultBlock
|
||||
@@ -324,6 +336,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
const documentNames = new Set<string>()
|
||||
// Mistral can reject replay IDs even when they satisfy Converse's broader ID syntax.
|
||||
const normalizeID = request.model.id.includes("mistral.") ? MistralToolID.normalizer(request) : (id: string) => id
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -347,7 +362,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
content.push(yield* BedrockMedia.lower(part))
|
||||
content.push(...(yield* BedrockMedia.lower(part, documentNames)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -388,7 +403,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
content.push(lowerToolCall(part))
|
||||
content.push(lowerToolCall(part, normalizeID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -400,7 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
|
||||
content.push(yield* lowerToolResult(part))
|
||||
content.push(yield* lowerToolResult(part, documentNames, normalizeID))
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "mistral-chat"
|
||||
const DONE = "[DONE]" as const
|
||||
const TOOL_ID = /^[A-Za-z0-9]{9}$/
|
||||
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
|
||||
@@ -223,34 +223,6 @@ const MistralEvent = Schema.StructWithRest(
|
||||
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
|
||||
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
|
||||
|
||||
const hashID = (value: string) => {
|
||||
const hash = (seed: number) => {
|
||||
let result = seed
|
||||
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
|
||||
}
|
||||
|
||||
const toolIDNormalizer = (request: LLMRequest) => {
|
||||
const ids = request.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
|
||||
)
|
||||
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
|
||||
const normalized = new Map<string, string>()
|
||||
return (id: string) => {
|
||||
if (TOOL_ID.test(id)) return id
|
||||
const previous = normalized.get(id)
|
||||
if (previous) return previous
|
||||
let attempt = 0
|
||||
let candidate = hashID(id)
|
||||
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
|
||||
used.add(candidate)
|
||||
normalized.set(id, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
|
||||
@@ -359,7 +331,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const normalizeID = toolIDNormalizer(request)
|
||||
const normalizeID = MistralToolID.normalizer(request)
|
||||
const messages: MistralMessage[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
for (const message of request.messages) {
|
||||
@@ -582,13 +554,13 @@ const appendContent = (
|
||||
}
|
||||
|
||||
const normalizeStreamToolID = (state: ParserState, source: string) => {
|
||||
if (TOOL_ID.test(source))
|
||||
if (MistralToolID.valid.test(source))
|
||||
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
|
||||
const previous = state.toolIDs.get(source)
|
||||
if (previous) return { id: previous, state }
|
||||
let attempt = 0
|
||||
let id = hashID(source)
|
||||
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
|
||||
let id = MistralToolID.hash(source)
|
||||
while (state.usedToolIDs.has(id)) id = MistralToolID.hash(`${source}:${++attempt}`)
|
||||
return {
|
||||
id,
|
||||
state: {
|
||||
|
||||
@@ -18,7 +18,6 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
|
||||
])
|
||||
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
|
||||
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
export interface Options {
|
||||
readonly id: string
|
||||
@@ -27,6 +26,7 @@ export interface Options {
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly continuation?: OpenResponsesContinuation.Shape
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
}),
|
||||
observe: (_create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -163,6 +163,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
continuation: options.continuation,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { OpenResponses } from "./open-responses.js"
|
||||
|
||||
const PROTOCOL = "open-responses.websocket.v1"
|
||||
const VERSION = 1
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
interface CheckpointValue {
|
||||
readonly version: typeof VERSION
|
||||
@@ -15,12 +14,19 @@ interface CheckpointValue {
|
||||
readonly output: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
|
||||
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
|
||||
*/
|
||||
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
|
||||
|
||||
export interface DriverInput {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly message: string
|
||||
readonly base: WebSocketChannelDriver
|
||||
readonly continuation?: Shape
|
||||
}
|
||||
|
||||
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
|
||||
@@ -127,22 +133,26 @@ const rejected = (
|
||||
|
||||
export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const { previous_response_id: _previousResponseID, ...request } = input.request
|
||||
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
|
||||
let output: OpenResponses.StreamItem[] = []
|
||||
return {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
output = []
|
||||
const previous = checkpointValue(checkpoint)
|
||||
const delta = previous ? incremental(request, previous) : undefined
|
||||
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
|
||||
const fields = previous ? shape(request) : undefined
|
||||
const delta = previous && fields ? incremental(request, previous) : undefined
|
||||
if (!previous || !fields || !delta)
|
||||
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
return {
|
||||
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
|
||||
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
|
||||
mode: "incremental" as const,
|
||||
}
|
||||
}),
|
||||
observe: (create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -195,4 +205,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -325,9 +325,8 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// The Responses schema puts streaming error details at the top level and
|
||||
// response failures under `response.error`. WebSocket failures use an
|
||||
// event-level `error` envelope, so accept all three shapes here.
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
@@ -401,10 +400,39 @@ export const Event = Schema.StructWithRest(
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((event) => {
|
||||
if (event.type !== "error" || event.error != null) return event
|
||||
const { code, message, param, ...rest } = event
|
||||
if (code === undefined && message === undefined && param === undefined) return event
|
||||
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
|
||||
return { ...rest, error: { code, message, param } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
|
||||
const decodeEventValue = Schema.decodeUnknownEffect(Event)
|
||||
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
|
||||
|
||||
/**
|
||||
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
|
||||
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
|
||||
*/
|
||||
export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
|
||||
@@ -766,7 +766,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
supportsStrictMode,
|
||||
),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
|
||||
...(zaiToolStream && hasActiveTools ? { tool_stream: true } : {}),
|
||||
|
||||
@@ -199,8 +199,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
|
||||
tool_choice:
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: (OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined)),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,6 +57,25 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
|
||||
},
|
||||
})
|
||||
|
||||
function documentName(filename: string | undefined, names: Set<string>) {
|
||||
const base =
|
||||
(filename ?? "")
|
||||
.replace(/\.[^.]*$/, "")
|
||||
.replace(/[^a-zA-Z0-9 ()[\]-]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 200)
|
||||
.trim() || "document"
|
||||
let name = base
|
||||
// Converse requires labels to be unique across the entire request, including tool results.
|
||||
for (let index = 2; names.has(name); index++) {
|
||||
const suffix = ` ${index}`
|
||||
name = `${base.slice(0, 200 - suffix.length).trimEnd()}${suffix}`
|
||||
}
|
||||
names.add(name)
|
||||
return name
|
||||
}
|
||||
|
||||
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
|
||||
@@ -72,19 +91,26 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: Media
|
||||
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
|
||||
// get an image-specific error so the caller knows it's a format-support issue,
|
||||
// not a kind-detection issue.
|
||||
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) {
|
||||
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
|
||||
return [{ image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock]
|
||||
}
|
||||
if (mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
|
||||
if (documentFormat) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
|
||||
const name = documentName(part.filename, documentNames)
|
||||
const block = documentBlock(name, documentFormat, yield* mediaBase64(part))
|
||||
return part.filename !== undefined && part.filename !== name
|
||||
? [
|
||||
{
|
||||
text: `Attached file ${ProviderShared.encodeJson(part.filename)} has document label ${ProviderShared.encodeJson(name)}.`,
|
||||
},
|
||||
block,
|
||||
]
|
||||
: [block]
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export const valid = /^[A-Za-z0-9]{9}$/
|
||||
|
||||
export const hash = (value: string) => {
|
||||
const hash = (seed: number) => {
|
||||
let result = seed
|
||||
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
|
||||
}
|
||||
|
||||
export const normalizer = (request: LLMRequest) => {
|
||||
const ids = request.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
|
||||
)
|
||||
// Reserve valid IDs before projecting any history, including IDs encountered later.
|
||||
const used = new Set(ids.filter((id) => valid.test(id)))
|
||||
const normalized = new Map<string, string>()
|
||||
return (id: string) => {
|
||||
if (valid.test(id)) return id
|
||||
const previous = normalized.get(id)
|
||||
if (previous) return previous
|
||||
let attempt = 0
|
||||
let candidate = hash(id)
|
||||
while (used.has(candidate)) candidate = hash(`${id}:${++attempt}`)
|
||||
used.add(candidate)
|
||||
normalized.set(id, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
export * as MistralToolID from "./mistral-tool-id.js"
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactionOperations } from "./route/client.js"
|
||||
|
||||
/**
|
||||
* Flat, serializable settings for `model(modelID, settings)`. Each entrypoint declares the connection keys it
|
||||
* reads; every other key is a request option for the route's protocol.
|
||||
*/
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Struct } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { AlibabaChat } from "../protocols/alibaba-chat.js"
|
||||
import { AlibabaMessages } from "../protocols/alibaba-messages.js"
|
||||
@@ -6,7 +7,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("alibaba")
|
||||
|
||||
@@ -34,9 +35,9 @@ export type Config = Location &
|
||||
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
|
||||
}
|
||||
export type Settings<Options = ChatOptionsInput> = Location &
|
||||
ProviderPackage.Settings & {
|
||||
ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
const hosts = new Map<string, string>([
|
||||
@@ -82,8 +83,13 @@ export const configure = (input: Config) => {
|
||||
? hosts.get(region)
|
||||
: `${workspaceID}.${region}.maas.aliyuncs.com`
|
||||
if (baseURL === undefined) {
|
||||
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
|
||||
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
|
||||
if (region === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
|
||||
if (host === undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
})
|
||||
}
|
||||
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
|
||||
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
|
||||
@@ -115,7 +121,11 @@ export const responsesModel: ProviderPackage.Definition<
|
||||
|
||||
function fromSettings(input: Settings<Config["providerOptions"]>) {
|
||||
const { body, ...rest } = input
|
||||
return configure({ ...rest, http: body === undefined ? undefined : { body } })
|
||||
return configure({
|
||||
...rest,
|
||||
http: body === undefined ? undefined : { body },
|
||||
providerOptions: Struct.omit(rest, ["apiKey", "baseURL", "headers", "region", "workspaceID"]),
|
||||
})
|
||||
}
|
||||
|
||||
export const webSearch = () => hostedTool("web_search", "Search the web with Alibaba's hosted search tool.")
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
@@ -23,16 +23,16 @@ export type Config = RouteDefaultsInput & {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
@@ -79,9 +79,12 @@ const defaults = (input: Config) => {
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
|
||||
})
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
@@ -105,18 +108,29 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const fromSettings = (settings: Settings) =>
|
||||
const fromSettings = ({
|
||||
apiKey,
|
||||
auth,
|
||||
baseURL,
|
||||
body,
|
||||
credentials,
|
||||
headers,
|
||||
profile,
|
||||
region,
|
||||
topP,
|
||||
...providerOptions
|
||||
}: Settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
apiKey,
|
||||
auth,
|
||||
baseURL,
|
||||
credentials,
|
||||
generation: topP === undefined ? undefined : { topP },
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
profile,
|
||||
providerOptions,
|
||||
region,
|
||||
})
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
@@ -39,8 +39,9 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
|
||||
if (auth === "sigv4" && apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
@@ -19,13 +19,13 @@ export type Config = RouteDefaultsInput &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
AnthropicMessages.ProviderOptionsInput &
|
||||
(
|
||||
| { readonly apiKey?: string; readonly authToken?: never }
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
@@ -36,8 +36,12 @@ const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
|
||||
const provider = input.provider ?? "anthropic-compatible"
|
||||
if (!input.baseURL)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: "Anthropic-compatible providers require a baseURL",
|
||||
})
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
@@ -59,17 +63,20 @@ export const provider = {
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, authToken, baseURL, body, headers, provider, ...providerOptions },
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
if (apiKey !== undefined && authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider ?? id),
|
||||
message: "Anthropic-compatible apiKey cannot be combined with authToken",
|
||||
})
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
provider,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
||||
|
||||
@@ -21,12 +21,12 @@ export type Config = RouteDefaultsInput &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
AnthropicMessages.ProviderOptionsInput &
|
||||
(
|
||||
| { readonly apiKey?: string; readonly authToken?: never }
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
@@ -54,15 +54,18 @@ export const configure = (input: Config = {}) => {
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, authToken, baseURL, body, headers, ...providerOptions },
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
if (apiKey !== undefined && authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Anthropic apiKey cannot be combined with authToken",
|
||||
})
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
@@ -28,12 +28,12 @@ export type LanguageModelOptions = AzureURL &
|
||||
export type Config = LanguageModelOptions
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput &
|
||||
AzureURL & {
|
||||
readonly apiKey?: string
|
||||
readonly apiVersion?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly useDeploymentBasedUrls?: boolean
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
@@ -151,19 +151,29 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
const config = ({
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
body,
|
||||
headers,
|
||||
queryParams,
|
||||
resourceName,
|
||||
useDeploymentBasedUrls,
|
||||
...providerOptions
|
||||
}: Settings): Config => {
|
||||
const common = {
|
||||
apiKey: settings.apiKey,
|
||||
apiVersion: settings.apiVersion,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
queryParams: queryParams === undefined ? undefined : { ...queryParams },
|
||||
useDeploymentBasedUrls,
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
if (baseURL !== undefined) return { ...common, baseURL }
|
||||
if (resourceName !== undefined) return { ...common, resourceName }
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "baseten-chat",
|
||||
@@ -48,13 +48,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Baseten from "./baseten.js"
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "cerebras-chat",
|
||||
@@ -52,11 +52,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-ai-gateway")
|
||||
@@ -27,15 +27,19 @@ export type LanguageModelOptions = GatewayURL &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput &
|
||||
GatewayURL & {
|
||||
readonly apiKey?: string
|
||||
readonly gatewayApiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
@@ -85,14 +89,25 @@ export const configure = (input: LanguageModelOptions) => {
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
gatewayApiKey: settings.gatewayApiKey,
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
const {
|
||||
accountId: _,
|
||||
apiKey,
|
||||
baseURL: _url,
|
||||
body,
|
||||
gatewayApiKey,
|
||||
gatewayId: _id,
|
||||
headers,
|
||||
...providerOptions
|
||||
} = settings
|
||||
return configure({
|
||||
apiKey,
|
||||
gatewayApiKey,
|
||||
baseURL: baseURL(settings),
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-workers-ai")
|
||||
@@ -21,14 +21,18 @@ export type LanguageModelOptions = WorkersAIURL &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput &
|
||||
WorkersAIURL & {
|
||||
readonly apiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const baseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
@@ -59,13 +63,15 @@ export const configure = (input: LanguageModelOptions) => {
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
const { accountId: _, apiKey, baseURL: _url, body, headers, ...providerOptions } = settings
|
||||
return configure({
|
||||
apiKey,
|
||||
baseURL: baseURL(settings),
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "deepinfra-chat",
|
||||
@@ -55,11 +55,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "deepseek-chat",
|
||||
@@ -52,13 +52,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as DeepSeek from "./deepseek.js"
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "fireworks-chat",
|
||||
@@ -48,13 +48,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Fireworks from "./fireworks.js"
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-chat",
|
||||
@@ -37,7 +37,8 @@ const route = Route.make({
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -73,15 +74,19 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
|
||||
) => {
|
||||
if (apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
accessToken,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
location,
|
||||
project,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
@@ -25,14 +25,14 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
AnthropicMessages.ProviderOptionsInput & {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-messages",
|
||||
@@ -67,7 +67,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Messages does not support API keys")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -105,16 +105,17 @@ export const provider = {
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
if (apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
accessToken,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
location,
|
||||
project,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenResponsesProviderOptionsInput & {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-responses",
|
||||
@@ -39,7 +39,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Responses does not support API keys")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -77,16 +77,17 @@ export const provider = {
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
if (apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
accessToken,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
location,
|
||||
project,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AnyAuthClient } from "google-auth-library"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Auth, MissingCredentialError } from "../route/auth.js"
|
||||
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
|
||||
|
||||
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
||||
const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type OAuthOptions =
|
||||
| { readonly accessToken?: string; readonly auth?: never }
|
||||
@@ -35,12 +37,18 @@ export const host = (location: string) => {
|
||||
|
||||
export const requireProject = (value: string | undefined) => {
|
||||
if (value) return value
|
||||
throw new Error("Google Vertex requires a project when baseURL is not configured")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex requires a project when baseURL is not configured",
|
||||
})
|
||||
}
|
||||
|
||||
export const apiKey = (input: ApiKeyOptions) => {
|
||||
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
|
||||
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
|
||||
}
|
||||
@@ -68,7 +76,10 @@ const adc = (project?: string) => {
|
||||
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex accessToken cannot be combined with auth",
|
||||
})
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||
@@ -26,6 +26,7 @@ export type Config = RouteDefaultsInput &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
GeminiProviderOptionsInput &
|
||||
(
|
||||
| { readonly accessToken?: string; readonly apiKey?: never }
|
||||
| { readonly accessToken?: never; readonly apiKey?: string }
|
||||
@@ -33,7 +34,6 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: GeminiProviderOptionsInput
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
@@ -93,7 +93,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
const apiKey = GoogleVertexShared.apiKey(input)
|
||||
const endpointModel = String(modelID).startsWith("endpoints/")
|
||||
if (apiKey !== undefined && endpointModel)
|
||||
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
})
|
||||
const location = GoogleVertexShared.location(inputLocation, "us-central1")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
const endpoint =
|
||||
@@ -121,16 +124,22 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
|
||||
) => {
|
||||
if (apiKey !== undefined && accessToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
...(apiKey === undefined ? { accessToken: accessToken } : { apiKey: apiKey }),
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
location,
|
||||
project,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
Gemini.ProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
@@ -57,13 +57,16 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const image = provider.image
|
||||
|
||||
@@ -26,11 +26,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ProviderOptions & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const Options = Schema.Struct({
|
||||
includeReasoning: Schema.optional(Schema.Boolean),
|
||||
@@ -103,13 +103,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Groq from "./groq.js"
|
||||
|
||||
@@ -79,11 +79,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "meta-responses",
|
||||
@@ -169,13 +169,13 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenResponsesProvid
|
||||
export const messagesModel: ProviderPackage.Definition<Settings, MessagesOptionsInput>["model"] = (modelID, settings) =>
|
||||
fromSettings(settings).messages(modelID)
|
||||
|
||||
function fromSettings(settings: Settings) {
|
||||
function fromSettings({ apiKey, baseURL, body, headers, ...providerOptions }: Settings) {
|
||||
return configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings<Options = MessagesOptionsInput> extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
export type Settings<Options = MessagesOptionsInput> = ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const ChatOptions = Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
|
||||
@@ -127,14 +127,14 @@ export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings<MessagesOptionsInput>, MessagesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const messages = provider.messages
|
||||
|
||||
@@ -3,11 +3,14 @@ import { MiniMax } from "../minimax.js"
|
||||
|
||||
export type Settings = MiniMax.Settings<MiniMax.ChatOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
MiniMax.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).chat(modelID)
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = MiniMax.Settings<MiniMax.ResponsesOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, MiniMax.ResponsesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
MiniMax.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).responses(modelID)
|
||||
|
||||
@@ -14,11 +14,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ProviderOptions & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = MistralChat.route
|
||||
export const routes = [route]
|
||||
@@ -39,13 +39,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Mistral from "./mistral.js"
|
||||
|
||||
@@ -42,11 +42,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const ChatOptions = Schema.Struct({
|
||||
reasoningEffort: Schema.optional(Schema.String),
|
||||
@@ -133,13 +133,16 @@ export const chat = provider.chat
|
||||
export const messages = provider.messages
|
||||
export const responses = provider.responses
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Moonshot from "./moonshot.js"
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.MessagesOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, Moonshot.MessagesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
Moonshot.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).messages(modelID)
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.ResponsesOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, Moonshot.ResponsesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
Moonshot.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).responses(modelID)
|
||||
|
||||
@@ -16,12 +16,12 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenResponsesProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleResponses.route]
|
||||
|
||||
@@ -48,13 +48,13 @@ export const provider = {
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
provider,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -14,12 +14,12 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleChat.route]
|
||||
|
||||
@@ -45,14 +45,17 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
provider,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
|
||||
@@ -57,14 +57,14 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||
},
|
||||
})
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly organization?: string
|
||||
readonly project?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly organization?: string
|
||||
readonly project?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||
|
||||
@@ -116,19 +116,28 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
const config = ({
|
||||
apiKey,
|
||||
baseURL,
|
||||
body,
|
||||
headers: given,
|
||||
organization,
|
||||
project,
|
||||
queryParams,
|
||||
...providerOptions
|
||||
}: Settings): Config => {
|
||||
const headers = {
|
||||
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
|
||||
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
|
||||
...settings.headers,
|
||||
...(organization === undefined ? {} : { "OpenAI-Organization": organization }),
|
||||
...(project === undefined ? {} : { "OpenAI-Project": project }),
|
||||
...given,
|
||||
}
|
||||
return {
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
queryParams: queryParams === undefined ? undefined : { ...queryParams },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,11 +77,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenRouterProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
|
||||
Schema.Record(Schema.String, Schema.Any),
|
||||
@@ -191,12 +191,12 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "togetherai-chat",
|
||||
@@ -52,11 +52,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -20,11 +20,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
XAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
|
||||
@@ -41,6 +41,10 @@ const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
|
||||
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
|
||||
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
|
||||
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
|
||||
}),
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
@@ -106,13 +110,13 @@ export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
XAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) =>
|
||||
>["model"] = (modelID, { apiKey, baseURL, body, headers, ...providerOptions }) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
|
||||
@@ -23,11 +23,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const chatRoute = Route.make({
|
||||
id: "zai-coding-chat",
|
||||
@@ -80,13 +80,16 @@ export const chat = provider.chat
|
||||
export const messages = provider.messages
|
||||
export const responses = provider.responses
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as ZAICodingPlan from "./zai-coding-plan.js"
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.MessagesOptionsInput
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.MessagesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
ZAICodingPlan.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).messages(modelID)
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.ResponsesOptionsInpu
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.ResponsesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
ZAICodingPlan.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).responses(modelID)
|
||||
|
||||
@@ -17,11 +17,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ChatOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ChatOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ChatOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export type { ZAIImageOptions } from "../protocols/zai-images.js"
|
||||
|
||||
@@ -70,13 +70,16 @@ export const provider = configure()
|
||||
export const image = provider.image
|
||||
export const chat = provider.chat
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as ZAI from "./zai.js"
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
LanguageModel,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputError,
|
||||
ProviderConfigurationError,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -128,7 +129,10 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
|
||||
})
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
...mapped,
|
||||
provider,
|
||||
|
||||
@@ -50,6 +50,19 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
|
||||
route: Schema.optional(RouteID),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Provider settings that are missing, conflicting, or unsupported, such as
|
||||
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
|
||||
* provider facade or package entrypoint configures a model, before any
|
||||
* request exists, so it is not an `AIError` reason.
|
||||
*/
|
||||
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
|
||||
"AI.Error.ProviderConfiguration",
|
||||
)("ProviderConfiguration", {
|
||||
provider: ProviderID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
|
||||
...ReasonFields,
|
||||
route: RouteID,
|
||||
|
||||
@@ -23,7 +23,13 @@ export interface DispatchResult extends ToolSettlement {
|
||||
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
|
||||
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
|
||||
const tool = tools[name]
|
||||
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
|
||||
if (!tool)
|
||||
return Effect.succeed(
|
||||
result(call, {
|
||||
type: "error",
|
||||
value: `No tool named "${name}" is currently available. Please use a tool from the available tool list.`,
|
||||
}),
|
||||
)
|
||||
if (!tool.execute)
|
||||
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { model } from "@opencode/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
const configuration = (provider: string, message: string) =>
|
||||
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
@@ -185,13 +188,13 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://provider.example.test/v1/",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" as const },
|
||||
reasoningEffort: "high" as const,
|
||||
}
|
||||
const deepinfra = DeepInfra.model("google/gemma-3-27b-it", settings)
|
||||
|
||||
expect(deepinfra.route.id).toBe("deepinfra-chat")
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://provider.example.test/v1/openai")
|
||||
expect(deepinfra.route.defaults.providerOptions).toEqual(settings.providerOptions)
|
||||
expect(deepinfra.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(deepinfra.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
|
||||
})
|
||||
@@ -207,7 +210,7 @@ describe("provider package entrypoints", () => {
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { custom: true },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
|
||||
@@ -228,11 +231,11 @@ describe("provider package entrypoints", () => {
|
||||
}
|
||||
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { usage: true },
|
||||
usage: true,
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
@@ -266,7 +269,8 @@ describe("provider package entrypoints", () => {
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "low", store: true },
|
||||
reasoningEffort: "low",
|
||||
store: true,
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
@@ -292,7 +296,7 @@ describe("provider package entrypoints", () => {
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { metadata: { user_id: "user_1" } },
|
||||
providerOptions: { effort: "low" },
|
||||
effort: "low",
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
@@ -312,7 +316,7 @@ describe("provider package entrypoints", () => {
|
||||
const Anthropic = await import("@opencode/ai/providers/anthropic")
|
||||
const selected = Anthropic.model("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
thinking: { type: "adaptive" },
|
||||
})
|
||||
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
||||
@@ -321,14 +325,16 @@ describe("provider package entrypoints", () => {
|
||||
test("requires an Anthropic-compatible base URL at runtime", async () => {
|
||||
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally bypasses static required-option checks.
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
|
||||
})
|
||||
|
||||
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
|
||||
const Anthropic = await import("@opencode/ai/providers/anthropic")
|
||||
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes a statically invalid option combination.
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, [
|
||||
"compatible-model",
|
||||
{
|
||||
@@ -337,10 +343,11 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
]),
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes a statically invalid option combination.
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
@@ -406,7 +413,7 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
thinkingConfig: { thinkingBudget: 1_024 },
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("gemini")
|
||||
@@ -486,47 +493,57 @@ describe("provider package entrypoints", () => {
|
||||
const GoogleVertexResponses = await import("@opencode/ai/providers/google-vertex/responses")
|
||||
const Providers = await import("@opencode/ai/providers")
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes a statically invalid option combination.
|
||||
Reflect.apply(GoogleVertex.model, undefined, [
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes a statically invalid option combination.
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
{ accessToken: "token", auth: {}, project: "vertex-project" },
|
||||
])
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow(
|
||||
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
|
||||
)
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
|
||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
|
||||
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
|
||||
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
expect(() =>
|
||||
// oxlint-disable-next-line no-restricted-globals -- This test intentionally passes an unsupported authentication option.
|
||||
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +76,13 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
|
||||
|
||||
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
|
||||
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
|
||||
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
|
||||
expect(() => Alibaba.configure({ region })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "alibaba",
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
}),
|
||||
)
|
||||
for (const config of [
|
||||
{ baseURL: "https://gateway.example/prefix" },
|
||||
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
|
||||
|
||||
@@ -69,7 +69,9 @@ for (const model of [
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
|
||||
expect(request.headers["anthropic-beta"]).toBe(
|
||||
"existing-beta,interleaved-thinking-2025-05-14,compact-2026-01-12",
|
||||
)
|
||||
if (body.messages.length === 1) {
|
||||
expect(body.context_management.edits).toEqual([
|
||||
{
|
||||
|
||||
@@ -32,7 +32,9 @@ for (const [id, enabled] of [
|
||||
enabled ? { type: "adaptive", block_binding: { prefix_mismatch_behavior: "drop_block" } } : undefined,
|
||||
)
|
||||
expect(prepared.request.headers["anthropic-beta"]).toBe(
|
||||
enabled ? "existing-beta,thinking-binding-controls-2026-08-01" : "existing-beta",
|
||||
enabled
|
||||
? "existing-beta,interleaved-thinking-2025-05-14,thinking-binding-controls-2026-08-01"
|
||||
: "existing-beta,interleaved-thinking-2025-05-14",
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -53,7 +55,9 @@ it.effect("preserves explicit thinking settings and combines required beta heade
|
||||
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
|
||||
expect(compiled.body.thinking).toEqual(thinking)
|
||||
expect(prepared.request.headers["anthropic-beta"]).toBe(
|
||||
thinking.type === "disabled" ? "compact-2026-01-12" : "compact-2026-01-12,thinking-binding-controls-2026-08-01",
|
||||
thinking.type === "disabled"
|
||||
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
|
||||
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import { Effect, Encoding, Ref, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
CacheHint,
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Tool,
|
||||
ToolCallPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
ToolRuntime,
|
||||
} from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
@@ -279,6 +281,52 @@ describe("Bedrock Converse route", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
;["", " \t\r\n"].forEach((description) => {
|
||||
it.effect(`omits blank tool description ${JSON.stringify(description)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description,
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig.tools).toEqual([
|
||||
{
|
||||
toolSpec: {
|
||||
name: "lookup",
|
||||
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(prepared.body.toolConfig.tools[0].toolSpec).not.toHaveProperty("description")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("preserves meaningful tool descriptions including surrounding whitespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const description = " \tLookup data.\n"
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description,
|
||||
inputSchema: { type: "object" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig.tools[0].toolSpec.description).toBe(description)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -348,6 +396,45 @@ describe("Bedrock Converse route", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ name: "browser.tabs.open", expected: "browser_tabs_open" },
|
||||
{ name: "$lookup", expected: "_lookup" },
|
||||
{ name: "", expected: "_" },
|
||||
{ name: " ", expected: "___" },
|
||||
{ name: "a".repeat(65), expected: "a".repeat(64) },
|
||||
{ name: "lookup_123-ABC", expected: "lookup_123-ABC" },
|
||||
{ name: "a".repeat(64), expected: "a".repeat(64) },
|
||||
].forEach((item) => {
|
||||
it.effect(`replays historical tool name ${JSON.stringify(item.name)} within Bedrock constraints`, () =>
|
||||
Effect.gen(function* () {
|
||||
const call = ToolCallPart.make({ id: "call_unknown", name: item.name, input: { query: "weather" } })
|
||||
const error = `No tool named "${item.name}" is currently available. Please use a tool from the available tool list.`
|
||||
const request = LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
tools: [ToolDefinition.make({ name: "execute", description: "Run code", inputSchema: { type: "object" } })],
|
||||
messages: [
|
||||
Message.user("Check the weather"),
|
||||
Message.assistant([call]),
|
||||
Message.tool({ id: call.id, name: call.name, result: error, resultType: "error" }),
|
||||
Message.user("Say OK"),
|
||||
],
|
||||
})
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body.messages[1].content).toEqual([
|
||||
{ toolUse: { toolUseId: call.id, name: item.expected, input: call.input } },
|
||||
])
|
||||
expect(prepared.body.messages[2].content).toEqual([
|
||||
{ toolResult: { toolUseId: call.id, content: [{ text: error }], status: "error" } },
|
||||
{ text: "Say OK" },
|
||||
])
|
||||
expect(prepared.body.toolConfig.tools.map((tool) => tool.toolSpec.name)).toEqual(["execute"])
|
||||
expect(request.messages[1].content[0]).toEqual(call)
|
||||
expect(call.name).toBe(item.name)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -751,6 +838,57 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a provider-emitted dotted name before normalizing its replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
[
|
||||
"contentBlockStart",
|
||||
{ contentBlockIndex: 0, start: { toolUse: { toolUseId: "call_unknown", name: "browser.tabs.open" } } },
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const call = response.toolCalls[0]
|
||||
if (!call) throw new Error("Expected a tool call")
|
||||
expect(call.name).toBe("browser.tabs.open")
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{
|
||||
browser_tabs_open: Tool.make({
|
||||
description: "Open a tab",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.String,
|
||||
execute: () => Effect.die("A normalized replay name must not select an executor"),
|
||||
}),
|
||||
},
|
||||
call,
|
||||
)
|
||||
expect(dispatched.result).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
'No tool named "browser.tabs.open" is currently available. Please use a tool from the available tool list.',
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [response.message, Message.tool({ id: call.id, name: call.name, result: dispatched.result })],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages[0].content).toEqual([
|
||||
{ toolUse: { toolUseId: call.id, name: "browser_tabs_open", input: {} } },
|
||||
])
|
||||
expect(response.toolCalls[0]?.name).toBe("browser.tabs.open")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores tool deltas without an open tool block", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -1458,7 +1596,13 @@ describe("Bedrock Converse route", () => {
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "amazon-bedrock",
|
||||
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
@@ -1621,29 +1765,141 @@ describe("Bedrock Converse route", () => {
|
||||
role: "user",
|
||||
content: [
|
||||
{ text: "Summarize these documents." },
|
||||
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
|
||||
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
|
||||
{ text: 'Attached file "report.pdf" has document label "report".' },
|
||||
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
|
||||
{ text: 'Attached file "data.csv" has document label "data".' },
|
||||
{ document: { format: "csv", name: "data", source: { bytes: "Q1NWREFUQQ==" } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{
|
||||
label: "filename punctuation",
|
||||
filename: "report_v1.2?.pdf",
|
||||
expected: "report v1 2",
|
||||
duplicate: "report v1 2 2",
|
||||
},
|
||||
{
|
||||
label: "repeated whitespace",
|
||||
filename: " Quarterly\t \n report.txt",
|
||||
expected: "Quarterly report",
|
||||
duplicate: "Quarterly report 2",
|
||||
},
|
||||
{
|
||||
label: "allowed characters",
|
||||
filename: "Report - Final (v2) [2026]",
|
||||
expected: "Report - Final (v2) [2026]",
|
||||
duplicate: "Report - Final (v2) [2026] 2",
|
||||
},
|
||||
{ label: "accented filename", filename: "résumé.pdf", expected: "r sum", duplicate: "r sum 2" },
|
||||
{ label: "non-Latin filename", filename: "報告書.pdf", expected: "document", duplicate: "document 2" },
|
||||
{ label: "missing filename", filename: undefined, expected: "document", duplicate: "document 2" },
|
||||
{ label: "empty filename", filename: "", expected: "document", duplicate: "document 2" },
|
||||
{ label: "blank filename", filename: " \t\n", expected: "document", duplicate: "document 2" },
|
||||
{ label: "extension-only filename", filename: ".pdf", expected: "document", duplicate: "document 2" },
|
||||
{ label: "symbols-only filename", filename: "@@@.pdf", expected: "document", duplicate: "document 2" },
|
||||
{
|
||||
label: "overlong filename",
|
||||
filename: `${"a".repeat(201)}.txt`,
|
||||
expected: "a".repeat(200),
|
||||
duplicate: `${"a".repeat(198)} 2`,
|
||||
},
|
||||
{
|
||||
label: "maximum-length label",
|
||||
filename: "a".repeat(200),
|
||||
expected: "a".repeat(200),
|
||||
duplicate: `${"a".repeat(198)} 2`,
|
||||
},
|
||||
{
|
||||
label: "whitespace at truncation",
|
||||
filename: `${"a".repeat(199)} b.txt`,
|
||||
expected: "a".repeat(199),
|
||||
duplicate: `${"a".repeat(198)} 2`,
|
||||
},
|
||||
].forEach((item) => {
|
||||
it.effect(`normalizes ${item.label} in user and tool-result documents`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Read this document" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: item.filename },
|
||||
]),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_read", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Read successfully" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;base64,UERGREFUQQ==",
|
||||
mime: "application/pdf",
|
||||
name: item.filename,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
const original = JSON.stringify(request.messages)
|
||||
const first = yield* compileRequest(request)
|
||||
const second = yield* compileRequest(request)
|
||||
const expected = { format: "pdf", name: item.expected, source: { bytes: "UERGREFUQQ==" } }
|
||||
|
||||
it.effect("requires names for document media", () =>
|
||||
expect(first.body.messages[0].content.find((part) => "document" in part)?.document).toEqual(expected)
|
||||
expect(
|
||||
first.body.messages[2].content[0].toolResult.content.find((part) => "document" in part)?.document,
|
||||
).toEqual({
|
||||
...expected,
|
||||
name: item.duplicate,
|
||||
})
|
||||
expect(second.body).toEqual(first.body)
|
||||
expect(JSON.stringify(request.messages)).toBe(original)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps colliding document labels distinct within a request", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Read these documents" },
|
||||
...["report_v1.txt", "report#v1.txt", "report v1 2.txt", "report v1.txt"].map((filename) => ({
|
||||
type: "media" as const,
|
||||
mediaType: "text/plain",
|
||||
data: "SGVsbG8=",
|
||||
filename,
|
||||
})),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("document media requires a filename")
|
||||
)
|
||||
expect(prepared.body.messages[0].content.slice(1)).toEqual([
|
||||
{ text: 'Attached file "report_v1.txt" has document label "report v1".' },
|
||||
{ document: { format: "txt", name: "report v1", source: { bytes: "SGVsbG8=" } } },
|
||||
{ text: 'Attached file "report#v1.txt" has document label "report v1 2".' },
|
||||
{ document: { format: "txt", name: "report v1 2", source: { bytes: "SGVsbG8=" } } },
|
||||
{ text: 'Attached file "report v1 2.txt" has document label "report v1 2 2".' },
|
||||
{ document: { format: "txt", name: "report v1 2 2", source: { bytes: "SGVsbG8=" } } },
|
||||
{ text: 'Attached file "report v1.txt" has document label "report v1 3".' },
|
||||
{ document: { format: "txt", name: "report v1 3", source: { bytes: "SGVsbG8=" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes named document-only messages through for provider validation", () =>
|
||||
it.effect("annotates renamed document-only messages with their original filename", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1663,12 +1919,44 @@ describe("Bedrock Converse route", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
|
||||
content: [
|
||||
{ text: 'Attached file "report.pdf" has document label "report".' },
|
||||
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("quotes original filenames in annotations and omits redundant mappings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Read these documents" },
|
||||
...["report", undefined, 'report "final"\n.pdf'].map((filename) => ({
|
||||
type: "media" as const,
|
||||
mediaType: "text/plain",
|
||||
data: "SGVsbG8=",
|
||||
filename,
|
||||
})),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages[0].content).toEqual([
|
||||
{ text: "Read these documents" },
|
||||
{ document: { format: "txt", name: "report", source: { bytes: "SGVsbG8=" } } },
|
||||
{ document: { format: "txt", name: "document", source: { bytes: "SGVsbG8=" } } },
|
||||
{ text: 'Attached file "report \\"final\\"\\n.pdf" has document label "report final".' },
|
||||
{ document: { format: "txt", name: "report final", source: { bytes: "SGVsbG8=" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers document media in tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1688,7 +1976,7 @@ describe("Bedrock Converse route", () => {
|
||||
type: "file",
|
||||
uri: "data:application/pdf;base64,UERGREFUQQ==",
|
||||
mime: "application/pdf",
|
||||
name: "report",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1711,6 +1999,7 @@ describe("Bedrock Converse route", () => {
|
||||
status: "success",
|
||||
content: [
|
||||
{ text: "Read successfully" },
|
||||
{ text: 'Attached file "report.pdf" has document label "report".' },
|
||||
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { BedrockConverse } from "../../src/protocols/bedrock-converse.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
const bedrock = AmazonBedrock.configure({ baseURL: "https://bedrock.test", apiKey: "test-key" })
|
||||
const history = (ids: string[]) => [
|
||||
Message.user("Read every label"),
|
||||
Message.assistant(ids.map((id, index) => ToolCallPart.make({ id, name: "lookup", input: { label: index } }))),
|
||||
...ids.map((id, index) => Message.tool({ id, name: "lookup", result: `value-${index}` })).toReversed(),
|
||||
]
|
||||
|
||||
for (const model of [
|
||||
"mistral.mistral-large-2407-v1:0",
|
||||
"us.mistral.pixtral-large-2502-v1:0",
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/mistral.pixtral-large-2502-v1:0",
|
||||
]) {
|
||||
it.effect(`preserves distinct call/result pairs and history for ${model}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model: bedrock.model(model), messages: history(["a"]), cache: "none" })
|
||||
const first = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(
|
||||
(yield* compileRequest(request)).body,
|
||||
)
|
||||
const reserved = first.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
|
||||
)[0]
|
||||
if (!reserved) throw new Error("Expected projected tool call")
|
||||
const ids = [
|
||||
"a",
|
||||
"b",
|
||||
"tooluse_GQn7COr2AN8bw2oVKYyYzZ",
|
||||
"tooluse_abcdefghi111111111",
|
||||
"tooluse_abcdefghi222222222",
|
||||
"call_other-provider-id",
|
||||
"Ab12Cd34E",
|
||||
reserved,
|
||||
]
|
||||
const messages = history(ids)
|
||||
const before = structuredClone(messages)
|
||||
const input = LLM.request({ model: bedrock.model(model), messages, cache: "none" })
|
||||
const prepared = yield* compileRequest(input)
|
||||
const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body)
|
||||
const calls = body.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
|
||||
)
|
||||
const results = body.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolResult" in part ? [part.toolResult.toolUseId] : [])),
|
||||
)
|
||||
expect(calls).toHaveLength(ids.length)
|
||||
expect(new Set(calls).size).toBe(ids.length)
|
||||
calls.forEach((id) => expect(id).toMatch(/^[A-Za-z0-9]{9}$/))
|
||||
expect(results).toEqual(calls.toReversed())
|
||||
expect(calls[0]).not.toBe(reserved)
|
||||
expect(calls.slice(-2)).toEqual(ids.slice(-2))
|
||||
expect((yield* compileRequest(input)).body).toEqual(prepared.body)
|
||||
expect(structuredClone(messages)).toEqual(before)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("leaves non-Mistral Bedrock IDs unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = ["a", "tooluse_abcdefghi111111111", "tooluse_abcdefghi222222222", "Ab12Cd34E"]
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrock.model("global.anthropic.claude-haiku-4-5-20251001-v1:0"),
|
||||
messages: history(ids),
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body)
|
||||
expect(
|
||||
body.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
|
||||
),
|
||||
).toEqual(ids)
|
||||
expect(
|
||||
body.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolResult" in part ? [part.toolResult.toolUseId] : [])),
|
||||
),
|
||||
).toEqual(ids.toReversed())
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { Auth } from "../../src/route/auth.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
const tool = { name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }
|
||||
|
||||
for (const route of [OpenAIChat.route, OpenAIResponses.route]) {
|
||||
const model = route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4.1-mini" })
|
||||
|
||||
for (const choice of [
|
||||
{ type: "auto" },
|
||||
{ type: "none" },
|
||||
{ type: "required" },
|
||||
{ type: "tool", name: "lookup" },
|
||||
] as const) {
|
||||
it.effect(`${route.id} omits ${choice.type} tool choice without active tools`, () =>
|
||||
Effect.gen(function* () {
|
||||
for (const messages of [
|
||||
[Message.user("Say OK.")],
|
||||
[
|
||||
Message.user("Look up the value."),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "OK", resultType: "text" }),
|
||||
],
|
||||
]) {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages, tools: [], toolChoice: choice, cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.tool_choice).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(`${route.id} preserves ${choice.type} tool choice with active tools`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Look up the value.", tools: [tool], toolChoice: choice }),
|
||||
)
|
||||
expect(prepared.body.tool_choice).toEqual(
|
||||
choice.type !== "tool"
|
||||
? choice.type
|
||||
: route.id === "openai-chat"
|
||||
? { type: "function", function: { name: "lookup" } }
|
||||
: { type: "function", name: "lookup" },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it.effect("OpenAI Responses omits allowed tool choice without tool definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4.1-mini" })
|
||||
for (const tools of [[], [tool]]) {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Look up the value.",
|
||||
tools,
|
||||
toolChoice: "required",
|
||||
providerOptions: { allowedTools: { mode: "required", toolNames: ["lookup"] } },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.tool_choice).toEqual(
|
||||
tools.length === 0
|
||||
? undefined
|
||||
: { type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "lookup" }] },
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -378,7 +378,11 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
test("rejects tuned Gemini models in express mode", () => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "google-vertex",
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,12 +34,10 @@ it.effect("Groq lowers its own options for custom catalog identities and endpoin
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-client": "test" },
|
||||
body: { custom: "value" },
|
||||
providerOptions: {
|
||||
reasoningEffort: "default",
|
||||
parallelToolCalls: true,
|
||||
serviceTier: "flex",
|
||||
user: "test-user",
|
||||
},
|
||||
reasoningEffort: "default",
|
||||
parallelToolCalls: true,
|
||||
serviceTier: "flex",
|
||||
user: "test-user",
|
||||
}),
|
||||
{ provider: "custom-groq" },
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ it.effect("Meta package selectors preserve overrides and Chat token policy on cu
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-client": "test" },
|
||||
body: { custom: "value" },
|
||||
providerOptions: { reasoningEffort: "future-effort" },
|
||||
reasoningEffort: "future-effort",
|
||||
})
|
||||
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
}
|
||||
const selected = provider.configure(settings).model("test-model")
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
@@ -143,7 +143,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
|
||||
],
|
||||
providerOptions: { store: true },
|
||||
store: true,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
]),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
providerOptions: { store: true },
|
||||
store: true,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = {
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
code: "server_shutting_down",
|
||||
message: "Server is shutting down. Please retry your request.",
|
||||
param: null,
|
||||
}
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
const event = yield* decode(JSON.stringify(frame))
|
||||
expect(event).toEqual({
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
error: { code: frame.code, message: frame.message, param: null },
|
||||
})
|
||||
|
||||
for (const unchanged of [
|
||||
event,
|
||||
{ type: "error" },
|
||||
{
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
|
||||
]) {
|
||||
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = `{
|
||||
"type": "error",
|
||||
"sequence_number": 4,
|
||||
"code": "server_shutting_down",
|
||||
"message": "Server is shutting down. Please retry your request.",
|
||||
"param": null,
|
||||
"diagnostic": "retain-original-frame"
|
||||
}`
|
||||
for (const model of [
|
||||
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
|
||||
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
|
||||
"example-model",
|
||||
),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
|
||||
expect(error.reason.body).toBe(raw)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -90,7 +90,11 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
|
||||
const continuationDriver = (
|
||||
request: Readonly<Record<string, unknown>>,
|
||||
base = baseChannelDriver,
|
||||
continuation?: OpenResponsesContinuation.Shape,
|
||||
) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenResponsesContinuation.driver({
|
||||
id: "openai-responses",
|
||||
@@ -98,6 +102,7 @@ const continuationDriver = (request: Readonly<Record<string, unknown>>, base = b
|
||||
request,
|
||||
message,
|
||||
base: base(message),
|
||||
continuation,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -921,6 +926,58 @@ describe("OpenAI Responses route", () => {
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
|
||||
})
|
||||
|
||||
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
|
||||
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
|
||||
const internal = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
|
||||
})
|
||||
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "ProviderInternal" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shapes the incremental send with the route continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
instructions: "You are terse.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
|
||||
}
|
||||
const secondRequest = {
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
}
|
||||
const saved = checkpoint(
|
||||
yield* continuationDriver(firstRequest).observe(
|
||||
yield* continuationDriver(firstRequest).create(undefined),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
|
||||
const trimmed = yield* continuationDriver(
|
||||
secondRequest,
|
||||
baseChannelDriver,
|
||||
({ instructions: _, ...rest }) => rest,
|
||||
).create(saved)
|
||||
expect(trimmed.mode).toBe("incremental")
|
||||
expect(JSON.parse(trimmed.message)).toEqual({
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
})
|
||||
|
||||
// Declining the continuation sends the step in full and never sends a previous_response_id.
|
||||
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
|
||||
expect(declined.mode).toBe("full")
|
||||
expect(JSON.parse(declined.message)).toEqual(secondRequest)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { XAIResponses } from "../../src/protocols/xai-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import {
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type ChannelCheckpoint,
|
||||
type WebSocketChannelDriver,
|
||||
} from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
@@ -13,6 +20,35 @@ import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
|
||||
|
||||
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
|
||||
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
|
||||
Effect.gen(function* () {
|
||||
let driver: WebSocketChannelDriver | undefined
|
||||
yield* LLMClient.generate(request, {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.sync(() => {
|
||||
driver = exchange.driver
|
||||
return { frames: exchange.fallback(), complete: Effect.void }
|
||||
}),
|
||||
},
|
||||
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
|
||||
if (!driver) throw new Error("Expected a WebSocket channel driver")
|
||||
return driver
|
||||
})
|
||||
|
||||
const completed = (driver: WebSocketChannelDriver, id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const create = yield* driver.create(undefined)
|
||||
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
|
||||
const observation = yield* driver.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
|
||||
)
|
||||
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
|
||||
return observation.checkpoint
|
||||
})
|
||||
|
||||
describe("xAI Responses route", () => {
|
||||
it.effect("composes the Open Responses baseline with xAI extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -162,6 +198,78 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
|
||||
Effect.gen(function* () {
|
||||
// xAI answers a rejected response.create with an error envelope that carries no event type.
|
||||
const envelope = ProviderShared.encodeJson({
|
||||
error: {
|
||||
message:
|
||||
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
|
||||
type: "api_error",
|
||||
},
|
||||
})
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
|
||||
})
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
|
||||
Effect.provide(
|
||||
LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
|
||||
expect(error.reason.body).toBe(envelope)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
|
||||
Effect.gen(function* () {
|
||||
const step = (store: boolean, ...prompts: string[]) =>
|
||||
LLM.request({
|
||||
model,
|
||||
system: "You are terse.",
|
||||
messages: prompts.map((prompt) => Message.user(prompt)),
|
||||
providerOptions: { store },
|
||||
})
|
||||
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
|
||||
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
|
||||
|
||||
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
|
||||
expect(stored.mode).toBe("incremental")
|
||||
expect(JSON.parse(stored.message)).toEqual({
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
})
|
||||
|
||||
// The connection cache only serves stored responses, so the default store: false never chains.
|
||||
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
|
||||
expect(unstored.mode).toBe("full")
|
||||
expect(JSON.parse(unstored.message)).toMatchObject({
|
||||
instructions: "You are terse.",
|
||||
store: false,
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "First" }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
|
||||
],
|
||||
})
|
||||
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
|
||||
|
||||
@@ -120,16 +120,16 @@ describe("LLMClient tools", () => {
|
||||
|
||||
const second = bodies[1]
|
||||
if (!second || typeof second !== "object") throw new Error("Expected second request body")
|
||||
const messages = Reflect.get(second, "messages")
|
||||
const tools = Reflect.get(second, "tools")
|
||||
const messages = "messages" in second ? second.messages : undefined
|
||||
const tools = "tools" in second ? second.tools : undefined
|
||||
|
||||
expect(Reflect.get(second, "max_completion_tokens")).toBe(50)
|
||||
expect(Reflect.get(second, "tool_choice")).toBe("auto")
|
||||
expect("max_completion_tokens" in second ? second.max_completion_tokens : undefined).toBe(50)
|
||||
expect("tool_choice" in second ? second.tool_choice : undefined).toBe("auto")
|
||||
expect(tools).toHaveLength(1)
|
||||
expect(
|
||||
Array.isArray(messages)
|
||||
? messages.map((message) =>
|
||||
message && typeof message === "object" ? Reflect.get(message, "role") : undefined,
|
||||
message && typeof message === "object" && "role" in message ? message.role : undefined,
|
||||
)
|
||||
: undefined,
|
||||
).toEqual(["user", "assistant", "tool"])
|
||||
@@ -237,13 +237,16 @@ describe("LLMClient tools", () => {
|
||||
LLMEvent.toolError({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
message: "Unknown tool: missing",
|
||||
message: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
result: { type: "error", value: "Unknown tool: missing" },
|
||||
result: {
|
||||
type: "error",
|
||||
value: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
|
||||
},
|
||||
providerMetadata,
|
||||
}),
|
||||
])
|
||||
@@ -395,7 +398,9 @@ describe("LLMClient tools", () => {
|
||||
required: ["temperature", "condition"],
|
||||
additionalProperties: false,
|
||||
})
|
||||
expect(Reflect.get(Reflect.get(typed?.outputSchema ?? {}, "properties") as object, "temperature")).toBeDefined()
|
||||
const properties =
|
||||
typed?.outputSchema && "properties" in typed.outputSchema ? typed.outputSchema.properties : undefined
|
||||
expect(properties && "temperature" in properties ? properties.temperature : undefined).toBeDefined()
|
||||
expect(dynamic?.outputSchema).toEqual(schema)
|
||||
})
|
||||
|
||||
@@ -712,12 +717,17 @@ describe("LLMClient tools", () => {
|
||||
|
||||
const toolError = events.find(LLMEvent.is.toolError)
|
||||
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
|
||||
expect(toolError?.message).toContain("Unknown tool")
|
||||
expect(toolError?.message).toBe(
|
||||
'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
|
||||
)
|
||||
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
|
||||
type: "tool-result",
|
||||
id: "call_1",
|
||||
name: "missing_tool",
|
||||
result: { type: "error", value: "Unknown tool: missing_tool" },
|
||||
result: {
|
||||
type: "error",
|
||||
value: 'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -19,11 +19,13 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
@@ -43,6 +45,17 @@ story("adding a server keeps all SSH challenges in the original connection dialo
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
|
||||
await mount("app-dialog-ssh--incompatible-host")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
|
||||
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
|
||||
@@ -63,20 +63,20 @@ for (const size of ["typical", "large"]) {
|
||||
const source = page.locator(`[data-timeline-part-id="${sourcePart}"] [data-component="markdown"]`)
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await page.waitForFunction(() => Reflect.get(window, "markdownGate").held)
|
||||
await page.waitForFunction(() => window.markdownGate.held)
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toBeAttached()
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Performance.enable")
|
||||
const before = await cdp.send("Performance.getMetrics")
|
||||
await page.evaluate(() => Reflect.get(window, "markdownGate").arm())
|
||||
await page.evaluate(() => window.markdownGate.arm())
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(source).toHaveAttribute("data-markdown-ready", "")
|
||||
await expect(source.getByRole("heading", { name: "Current destination" })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toHaveCount(0)
|
||||
await page.waitForFunction(() => Reflect.get(window, "markdownGate").settled > 0)
|
||||
await page.waitForFunction(() => window.markdownGate.settled > 0)
|
||||
const after = await cdp.send("Performance.getMetrics")
|
||||
const stats = await page.evaluate(() => {
|
||||
const value = Reflect.get(window, "markdownGate")
|
||||
const value = window.markdownGate
|
||||
return {
|
||||
admitted: value.admitted,
|
||||
responses: value.responses,
|
||||
|
||||
@@ -4,6 +4,23 @@ import type {
|
||||
MarkdownWorkerResponse,
|
||||
} from "../../../../session-ui/src/components/markdown-worker-protocol"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
markdownGate: {
|
||||
admitted: number
|
||||
responses: number
|
||||
held: boolean
|
||||
started: number
|
||||
ready: number
|
||||
released: number
|
||||
settled: number
|
||||
sanitizeCalls: number
|
||||
sanitizeChars: number
|
||||
arm: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function installMarkdownGate(
|
||||
page: Page,
|
||||
input: { answer: string; sourcePart: string; targetPart: string; href: string },
|
||||
|
||||
@@ -51,7 +51,7 @@ Terminal.prototype.open = function (element) {
|
||||
context.drawImage = function (...args: unknown[]) {
|
||||
probe.draws++
|
||||
if (hidden) probe.hiddenDraws++
|
||||
Reflect.apply(draw, this, args)
|
||||
draw.apply(this, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ for (const scenario of ["visible-output", "hidden-output", "full-scrollback-tear
|
||||
window.terminalProbe.draws++
|
||||
if (!this.canvas.checkVisibility()) window.terminalProbe.hiddenDraws++
|
||||
}
|
||||
Reflect.apply(fill, this, args)
|
||||
fill.apply(this, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function installTimelineStreamProbe(
|
||||
}
|
||||
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
|
||||
state.scroll.lastCallFrame = state.scroll.frame
|
||||
Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
|
||||
scrollTo.apply(this, typeof first === "number" ? [first, second] : [first])
|
||||
}
|
||||
Element.prototype.scrollTo = measuredScrollTo
|
||||
Object.defineProperty(Element.prototype, "scrollTop", {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("loads home and the directory picker without newer browser APIs", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
// Safari 16.6 has neither API. Remove them before the web entry runs.
|
||||
delete (Map as Partial<typeof Map>).groupBy
|
||||
delete (Promise as Partial<typeof Promise>).withResolvers
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Add project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(row).toBeVisible()
|
||||
})
|
||||
@@ -1,195 +0,0 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/slash-skills"
|
||||
const projectID = "proj_slash_skills"
|
||||
const sessionID = "ses_slash_skills"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
async function setup(page: Page, queued = false) {
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "slash-skills",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
projectID,
|
||||
directory,
|
||||
title: "Slash skills",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
sessionStatus: queued ? { [sessionID]: { type: "running" } } : {},
|
||||
inbox: queued
|
||||
? [
|
||||
{
|
||||
id: "inb_slash_skill",
|
||||
sessionID,
|
||||
timeCreated: 1700000000000,
|
||||
type: "user",
|
||||
payload: { text: "Explain caching" },
|
||||
delivery: "queue",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
findFiles: ({ query }) =>
|
||||
query.includes("cache")
|
||||
? [
|
||||
{
|
||||
name: "cache.ts",
|
||||
path: "src/cache.ts",
|
||||
absolute: `${directory}/src/cache.ts`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
onPrompt: ({ body }) => prompts.push(body),
|
||||
})
|
||||
await page.route("**/api/skill?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: [
|
||||
{ id: "show-me", slash: true, autoinvoke: false },
|
||||
{ id: "hidden", slash: false },
|
||||
{ id: "implicit" },
|
||||
{ id: "review", slash: true },
|
||||
{ id: "model", slash: true },
|
||||
].map((skill) => ({
|
||||
name: skill.id === "show-me" ? "Show Me" : skill.id,
|
||||
description: "Explain the current topic visually",
|
||||
location: `/skills/${skill.id}/SKILL.md`,
|
||||
content: "Explain visually",
|
||||
...skill,
|
||||
})),
|
||||
},
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/command?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory, project: { id: projectID, directory, canonical: directory } },
|
||||
data: [{ name: "review", description: "Review changes" }],
|
||||
},
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const editor = composer.locator('[data-component="composer-editor"]')
|
||||
await expect(editor).toBeEditable()
|
||||
return { prompts, composer, editor }
|
||||
}
|
||||
|
||||
for (const selection of ["keyboard", "pointer"]) {
|
||||
test(`selects and submits a manual-only slash skill with ${selection}`, async ({ page }) => {
|
||||
const { prompts, editor } = await setup(page)
|
||||
await editor.fill("/show")
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toContainText("/show-me")
|
||||
await page.screenshot({ path: test.info().outputPath("slash-menu.png") })
|
||||
if (selection === "keyboard") await editor.press("Enter")
|
||||
if (selection === "pointer") await skill.click()
|
||||
await expect(editor).toHaveText("/show-me ")
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.pressSequentially("explain caching")
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
text: "/show-me explain caching",
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
})
|
||||
await expect(editor).toBeEmpty()
|
||||
})
|
||||
}
|
||||
|
||||
test("respects slash flags and command precedence without hiding context skills", async ({ page }) => {
|
||||
const { editor } = await setup(page)
|
||||
await editor.fill("/")
|
||||
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toBeVisible()
|
||||
await expect(page.locator('[data-suggestion-id="custom.review"]')).toBeVisible()
|
||||
await expect(page.locator('[data-suggestion-id="model.choose"]')).toBeVisible()
|
||||
for (const id of ["hidden", "implicit", "review", "model"]) {
|
||||
await expect(page.locator(`[data-suggestion-id="skill:${id}"]`)).toHaveCount(0)
|
||||
}
|
||||
await editor.press("Escape")
|
||||
await expect(page.locator('[data-suggestion-id="skill:show-me"]')).toHaveCount(0)
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.fill("@hidden")
|
||||
const hidden = page.locator('[data-suggestion-id="skill:hidden"]')
|
||||
await expect(hidden).toContainText("@hidden")
|
||||
await hidden.click()
|
||||
await expect(editor).toHaveText("@hidden ")
|
||||
await expect(editor).toBeFocused()
|
||||
})
|
||||
|
||||
test("preserves structured attachments when adding a slash skill from the command menu", async ({ page }) => {
|
||||
const { prompts, composer, editor } = await setup(page)
|
||||
await editor.fill("explain @cache")
|
||||
const file = page.locator('[data-suggestion-id="file:src/cache.ts"]')
|
||||
await expect(file).toBeVisible()
|
||||
await file.click()
|
||||
await expect(editor).toHaveText("explain @src/cache.ts ")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toBeVisible()
|
||||
await skill.click()
|
||||
await expect(editor).toHaveText("/show-me explain @src/cache.ts ")
|
||||
await expect(editor).toBeFocused()
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
files: [{ name: "cache.ts", mention: { start: 17, end: 30, text: "@src/cache.ts" } }],
|
||||
})
|
||||
})
|
||||
|
||||
for (const select of [true, false]) {
|
||||
test(`keeps slash skills in queued edits (${select ? "selected" : "typed"})`, async ({ page }) => {
|
||||
const { prompts, editor } = await setup(page, true)
|
||||
const row = page.locator('[data-component="session-queue-row"]')
|
||||
await row.getByText("Explain caching", { exact: true }).click()
|
||||
await expect(editor).toHaveText("Explain caching")
|
||||
if (select) {
|
||||
await editor.fill("/show")
|
||||
const skill = page.locator('[data-suggestion-id="skill:show-me"]')
|
||||
await expect(skill).toBeVisible()
|
||||
await skill.click()
|
||||
await expect(editor).toHaveText("/show-me ")
|
||||
await editor.pressSequentially("explain caching")
|
||||
}
|
||||
if (!select) await editor.fill("/show-me explain caching")
|
||||
await editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(prompts[0]).toMatchObject({
|
||||
delivery: "queue",
|
||||
text: "/show-me explain caching",
|
||||
skills: [{ id: "show-me", mention: { start: 0, end: 8, text: "/show-me" } }],
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -62,7 +62,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
@@ -76,7 +76,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
if (url.pathname === "/api/project") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
canonical: current.directory,
|
||||
@@ -84,7 +84,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
return json(route, [project])
|
||||
}
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
|
||||
@@ -59,8 +59,9 @@ for (const shared of [true, false]) {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+;")
|
||||
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
const toggle = dialog.getByRole("switch")
|
||||
await expect(toggle).not.toBeChecked()
|
||||
@@ -92,7 +93,7 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -113,6 +114,10 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
requests.push({ path: url.pathname, directory: target })
|
||||
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
|
||||
state.status = "disabled"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/figma-desktop/connect") {
|
||||
state.status = state.fail ? "failed" : "connected"
|
||||
// Connection failures are reported by the refreshed status, not the HTTP response.
|
||||
@@ -137,12 +142,19 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
const panel =
|
||||
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
if (surface === "popover") {
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
|
||||
const toggle = panel.getByRole("switch")
|
||||
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await expect(toggle).toBeChecked()
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
}
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
requests.length = 0
|
||||
@@ -152,7 +164,7 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
.getByRole("listitem", { includeHidden: true })
|
||||
.filter({ has: page.getByText("Request failed", { exact: true }) })
|
||||
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeChecked({ checked: surface === "popover" })
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
|
||||
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
|
||||
@@ -167,9 +179,18 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
|
||||
await expect(toast).toBeHidden()
|
||||
state.fail = false
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
if (surface === "popover") {
|
||||
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
}
|
||||
await panel.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
|
||||
@@ -104,18 +104,12 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
+5
-4
@@ -2,20 +2,21 @@ import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
await expect
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
const directory = "/workspace/summary-project"
|
||||
const workspace = "/workspace/existing-worktree"
|
||||
const createdWorkspace = "/workspace/created-worktree"
|
||||
const draftID = "draft_summary"
|
||||
const secondDraftID = "draft_summary_other"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
|
||||
for (const rtl of [false, true]) {
|
||||
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const mock = await openDraft(page, "main", { direction: rtl ? "rtl" : "ltr" })
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", rtl ? "rtl" : "ltr")
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await trigger.boundingBox()
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
if (!button || !view) return false
|
||||
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
|
||||
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
|
||||
})
|
||||
.toBe(true)
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Extensions", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await trigger.boundingBox()
|
||||
const project = await summary.locator('[data-section="project"]').boundingBox()
|
||||
const server = await summary.locator('[data-section="server"]').boundingBox()
|
||||
if (!button || !project || !server) return
|
||||
return {
|
||||
top: project.y - button.y - button.height,
|
||||
cards: server.y - project.y - project.height,
|
||||
}
|
||||
})
|
||||
.toEqual({ top: 12, cards: 8 })
|
||||
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const [name, item] of [
|
||||
["MCP", "summary-mcp"],
|
||||
["Plugins", "project-plugin"],
|
||||
["Skills", "summary-skill"],
|
||||
["LSP", "typescript"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const menu = await worktreeMenu.boundingBox()
|
||||
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
|
||||
if (!menu || !panel) return false
|
||||
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
|
||||
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
|
||||
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await mcp.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("non-Git folders show their status without offering worktree actions", async ({ page }) => {
|
||||
await openDraft(page, "main", { git: false })
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByText("No Git", { exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Local repository", exact: true })).toHaveCount(0)
|
||||
await expect(summary.getByRole("button", { name: "New worktree", exact: true })).toHaveCount(0)
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "MCP", exact: true }).getByRole("switch", { name: "summary-mcp", exact: true }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
|
||||
await expect(menu).toHaveCSS("opacity", "1")
|
||||
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
|
||||
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
|
||||
)?.mcp?.states,
|
||||
),
|
||||
)
|
||||
.toEqual({ "summary-mcp": false })
|
||||
|
||||
await page.goto(`/new-session?draftId=${secondDraftID}`)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.goto(draftPath)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "worktree", directory },
|
||||
{ type: "mcp", directory: createdWorkspace, enabled: false },
|
||||
{ type: "session", directory: createdWorkspace },
|
||||
{ type: "prompt", directory: createdWorkspace },
|
||||
])
|
||||
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
})
|
||||
|
||||
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
try {
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
|
||||
expect(mock.prompts).toEqual([])
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "mcp", directory, enabled: false },
|
||||
{ type: "session", directory },
|
||||
{ type: "prompt", directory },
|
||||
])
|
||||
})
|
||||
|
||||
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
mock.state.holdDirectory = directory
|
||||
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
try {
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.state.fail = true
|
||||
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
|
||||
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
|
||||
expect(mock.prompts).toEqual([])
|
||||
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
|
||||
mock.state.fail = false
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(mock.status.get(createdWorkspace)).toBe("disabled")
|
||||
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
|
||||
})
|
||||
|
||||
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.status.set(createdWorkspace, "needs_auth")
|
||||
const attempts: string[] = []
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
return route.fulfill({
|
||||
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: createdWorkspace },
|
||||
data: {
|
||||
id: "summary-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
const popup = page.waitForEvent("popup")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
|
||||
expect(attempts).toEqual([createdWorkspace])
|
||||
expect(mock.prompts).toEqual([])
|
||||
mock.status.set(createdWorkspace, "connected")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(attempts).toHaveLength(1)
|
||||
})
|
||||
|
||||
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
name: "summary-project",
|
||||
vcs: options.git === false ? undefined : "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const sessions: ReturnType<typeof currentSession>[] = []
|
||||
const status = new Map<string, string>([
|
||||
[directory, "connected"],
|
||||
[workspace, "disabled"],
|
||||
[createdWorkspace, "connected"],
|
||||
])
|
||||
const calls: { type: string; directory: string; enabled?: boolean }[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "summary-model" },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt(input) {
|
||||
const session = sessions.find((session) => session.id === input.sessionID)
|
||||
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
|
||||
calls.push({ type: "prompt", directory: session.location.directory })
|
||||
prompts.push(input)
|
||||
},
|
||||
})
|
||||
if (options.git === false) {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/vcs",
|
||||
(route) => route.fulfill({ json: { location: { directory }, data: { branch: {} } } }),
|
||||
)
|
||||
}
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
if (route.request().method() === "POST") {
|
||||
const enabled = url.pathname.endsWith("/connect")
|
||||
calls.push({ type: "mcp", directory: target, enabled })
|
||||
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
|
||||
status.set(target, enabled ? "connected" : "disabled")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{
|
||||
name: "summary-mcp",
|
||||
integrationID: "summary-oauth",
|
||||
status: { status: status.get(target) ?? "connected" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/location",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
|
||||
project: { id: project.id, directory, canonical: directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
(route) => {
|
||||
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
|
||||
const id = target === directory ? "project-plugin" : "workspace-plugin"
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/skill",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [
|
||||
{
|
||||
id: "summary-skill",
|
||||
name: "summary-skill",
|
||||
path: "/skills/summary/SKILL.md",
|
||||
content: "Summary skill",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
calls.push({ type: "worktree", directory })
|
||||
project.sandboxes.push(createdWorkspace)
|
||||
return route.fulfill({ json: { directory: createdWorkspace } })
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/session",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
|
||||
calls.push({ type: "session", directory: body.location.directory })
|
||||
const session = currentSession(
|
||||
{ ...body, projectID: project.id, title: "Created summary session" },
|
||||
body.location.directory,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session } })
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, draftID, secondDraftID, worktree },
|
||||
)
|
||||
if (options.direction) await openWithDirection(page, draftPath, options.direction)
|
||||
if (!options.direction) await page.goto(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
|
||||
if (options.git === false) await expect(page.getByText("No Git", { exact: true })).toBeVisible()
|
||||
if (options.git !== false) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
|
||||
).toBeVisible()
|
||||
}
|
||||
return { calls, prompts, status, state }
|
||||
}
|
||||
@@ -248,7 +248,7 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
expect(messageAfter).toEqual(messageBefore)
|
||||
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
|
||||
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { ProjectUpdateInput } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/project-autosave"
|
||||
const project = {
|
||||
id: "proj_autosave",
|
||||
canonical: directory,
|
||||
name: "Autosave project",
|
||||
icon: { color: "orange" },
|
||||
commands: { start: "echo setup" },
|
||||
sandboxes: [],
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
for (const field of [
|
||||
{
|
||||
name: "color",
|
||||
label: "",
|
||||
initial: "orange",
|
||||
next: "blue",
|
||||
patches: [{ icon: { color: "blue", override: "" } }, { icon: { color: "orange", override: "" } }],
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
label: "Project name",
|
||||
initial: project.name,
|
||||
next: "Renamed project",
|
||||
patches: [{ name: "Renamed project" }, { name: project.name }],
|
||||
},
|
||||
{
|
||||
name: "startup script",
|
||||
label: "Worktree startup script",
|
||||
initial: project.commands.start,
|
||||
next: "bun install",
|
||||
patches: [{ commands: { start: "bun install" } }, { commands: { start: project.commands.start } }],
|
||||
},
|
||||
]) {
|
||||
test(`keeps the final project ${field.name} when reverting during an autosave`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [{ type: "http", http: { url: server } }],
|
||||
projects: {
|
||||
local: [{ worktree: directory, expanded: true }],
|
||||
[server]: [{ worktree: directory, expanded: true }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, server },
|
||||
)
|
||||
const pending = Promise.withResolvers<void>()
|
||||
const patches: unknown[] = []
|
||||
await page.route(`**/api/project/${project.id}`, async (route) => {
|
||||
const patch: Pick<ProjectUpdateInput, "name" | "icon" | "commands"> = route.request().postDataJSON()
|
||||
patches.push(patch)
|
||||
if (patches.length === 1) await pending.promise
|
||||
await route.fulfill({ json: { ...project, ...patch } })
|
||||
})
|
||||
await page.goto("/settings")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: project.name, exact: true }).click()
|
||||
const edit = async (value: string) => {
|
||||
if (field.name === "color") {
|
||||
await settings.getByRole("button", { name: `Select ${value} color`, exact: true }).click()
|
||||
return
|
||||
}
|
||||
const input = settings.getByRole("textbox", { name: field.label, exact: true })
|
||||
await input.fill(value)
|
||||
await input.blur()
|
||||
}
|
||||
|
||||
const first = page.waitForRequest(
|
||||
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === `/api/project/${project.id}`,
|
||||
)
|
||||
await edit(field.next)
|
||||
await first
|
||||
await edit(field.initial)
|
||||
expect(patches).toEqual([field.patches[0]])
|
||||
pending.resolve()
|
||||
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
|
||||
expect(patches).toEqual(field.patches)
|
||||
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: project.name, exact: true }).click()
|
||||
if (field.name === "color") {
|
||||
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
)
|
||||
return
|
||||
}
|
||||
await expect(settings.getByRole("textbox", { name: field.label, exact: true })).toHaveValue(field.initial)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { ConfigEntry } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/configured-lsp"
|
||||
const entries: ConfigEntry[] = [
|
||||
{
|
||||
type: "document",
|
||||
path: "/config/opencode.json",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
path: `${directory}/opencode.jsonc`,
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { disabled: true },
|
||||
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_configured_lsp",
|
||||
canonical: directory,
|
||||
name: "Configured LSP project",
|
||||
sandboxes: [],
|
||||
time: { created: 1, updated: 1 },
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
})
|
||||
|
||||
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
|
||||
const ready = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
async (route) => {
|
||||
await ready.promise
|
||||
await route.fulfill({ json: entries })
|
||||
},
|
||||
)
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
|
||||
ready.resolve()
|
||||
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
|
||||
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
|
||||
await expect(typescript).toContainText("Disabled in config")
|
||||
await expect(typescript).toContainText(".ts, .tsx")
|
||||
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
|
||||
"Enabled in config",
|
||||
)
|
||||
await expect(panel.getByRole("switch")).toHaveCount(0)
|
||||
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
|
||||
await expect
|
||||
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
for (const lsp of [true, false]) {
|
||||
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [{ type: "document", info: { lsp } }],
|
||||
}),
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(
|
||||
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
|
||||
const state = { fail: true }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: state.fail ? 404 : 200,
|
||||
json: state.fail ? {} : entries,
|
||||
}),
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
|
||||
state.fail = false
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
|
||||
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -34,8 +34,10 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
|
||||
await expect(settings.getByRole("complementary")).toHaveCSS("width", "328px")
|
||||
await expect(sessionHeading).toBeHidden()
|
||||
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
|
||||
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const input = autoAccept.getByRole("switch")
|
||||
await expect(autoAccept).toBeVisible()
|
||||
@@ -63,9 +65,21 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
},
|
||||
])
|
||||
|
||||
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
|
||||
await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab")).toHaveText([
|
||||
"127.0.0.1:4097",
|
||||
"Projects",
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
])
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "Back to settings" }).click()
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
@@ -73,7 +87,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
@@ -382,8 +396,6 @@ async function mockServers(
|
||||
},
|
||||
])
|
||||
}
|
||||
if (url.pathname === "/api/project/current")
|
||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session/active")
|
||||
@@ -402,6 +414,8 @@ async function mockServers(
|
||||
directory,
|
||||
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
|
||||
})
|
||||
if (url.pathname === "/api/config/shell") return json(route, [])
|
||||
if (url.pathname === "/api/websearch/provider") return json(route, { location: { directory }, data: [] })
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { expect, test, type Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -13,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -58,6 +60,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
searches.push(input)
|
||||
return input.query === "nested" ? ["src/nested.ts"] : []
|
||||
},
|
||||
events: () => events.splice(0),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
@@ -126,6 +129,35 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
|
||||
const missingReadPattern = "**/api/fs/read/README.md*"
|
||||
const missingRead = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
_tag: "FileNotFoundError",
|
||||
path: "README.md",
|
||||
message: "File not found: README.md",
|
||||
}),
|
||||
})
|
||||
await page.route(missingReadPattern, missingRead)
|
||||
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
|
||||
events.push(filesystemEvent("README.md", "unlink"))
|
||||
await missingResponse
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
|
||||
|
||||
const missingTab = panel.getByRole("tab", { name: "File not found: README.md", selected: true })
|
||||
await expect(missingTab).toBeVisible()
|
||||
await expect(missingTab.locator("[data-file-not-found]")).toHaveCSS("text-decoration-line", "line-through")
|
||||
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
|
||||
|
||||
await page.unroute(missingReadPattern, missingRead)
|
||||
events.push(filesystemEvent("README.md", "add"))
|
||||
const restoredTab = panel.getByRole("tab", { name: "README.md", selected: true })
|
||||
await expect(restoredTab).toBeVisible()
|
||||
await expect(restoredTab.locator("[data-file-not-found]")).toHaveCount(0)
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||
await expect(sidebar).toBeVisible()
|
||||
@@ -155,6 +187,16 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||
})
|
||||
|
||||
function filesystemEvent(file: string, event: "add" | "change" | "unlink"): OpenCodeEvent {
|
||||
return {
|
||||
id: `evt_${file}_${event}`,
|
||||
created: 1,
|
||||
type: "filesystem.changed",
|
||||
location: { directory },
|
||||
data: { file, event },
|
||||
}
|
||||
}
|
||||
|
||||
function fileNode(path: string) {
|
||||
return {
|
||||
name: path,
|
||||
|
||||
@@ -16,8 +16,8 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
})
|
||||
}
|
||||
if (url.pathname === "/api/global/health" || url.pathname === "/api/health") {
|
||||
return json(route, { healthy: true, version: "2.0.0" })
|
||||
if (url.pathname === "/api/status") {
|
||||
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
|
||||
}
|
||||
return json(route, {})
|
||||
})
|
||||
@@ -27,8 +27,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await settings.getByRole("tab", { name: "Servers" }).click()
|
||||
await settings.getByRole("button", { name: "Add server" }).click()
|
||||
const add = settings.getByRole("button", { name: "Add server" })
|
||||
const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" })
|
||||
await expect(add).toHaveCSS("opacity", "0")
|
||||
await group.hover()
|
||||
await expect(add).toHaveCSS("opacity", "1")
|
||||
await add.click()
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Add server" })
|
||||
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
|
||||
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(review).toBeVisible()
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect(status).toHaveCount(0)
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
for (const editing of [false, true]) {
|
||||
@@ -122,12 +122,11 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
|
||||
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
|
||||
await expect(mcp).toHaveAttribute("aria-selected", "true")
|
||||
await plugins.click()
|
||||
await expect(plugins).toHaveAttribute("aria-selected", "true")
|
||||
await details.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
await expect(mcp).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(mcp).toBeHidden()
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user