mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 08:25:00 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e6500b52f |
@@ -4,13 +4,6 @@
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
## 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.
|
||||
- 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 `next` 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.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
@@ -67,7 +67,6 @@
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
@@ -88,6 +87,7 @@
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
@@ -143,10 +143,7 @@
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -383,6 +380,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -403,11 +401,9 @@
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -424,6 +420,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -517,7 +514,6 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
@@ -1077,11 +1073,9 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
@@ -4078,10 +4072,10 @@
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -5640,10 +5634,6 @@
|
||||
|
||||
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
||||
|
||||
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
@@ -6866,8 +6856,6 @@
|
||||
|
||||
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.0", "", {}, "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q=="],
|
||||
|
||||
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 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",
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
|
||||
## Tests
|
||||
|
||||
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
|
||||
|
||||
+23
-23
@@ -60,7 +60,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
||||
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
|
||||
|
||||
## Highest-Risk Gaps
|
||||
|
||||
@@ -71,31 +71,30 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
||||
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
|
||||
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
|
||||
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
|
||||
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection.
|
||||
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Bedrock Mantle, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure and Vertex still need first-class recorded scenarios before switching defaults.
|
||||
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection; the missing native boundary is Bedrock Mantle.
|
||||
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults.
|
||||
|
||||
## Native Namespace Shape
|
||||
|
||||
These are implementation/API slices, not separate npm packages.
|
||||
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
|
||||
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
|
||||
## Suggested Next Work Slices
|
||||
|
||||
@@ -104,5 +103,6 @@ These are implementation/API slices, not separate npm packages.
|
||||
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
|
||||
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints.
|
||||
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
|
||||
6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
|
||||
7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, and Bedrock credential-chain behavior before making native runtime the default for those packages.
|
||||
6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model.
|
||||
7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
|
||||
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
|
||||
|
||||
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
@@ -133,7 +133,6 @@ const countHints = (request: LLMRequest) =>
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
|
||||
@@ -29,30 +29,9 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
readonly safetySettings?: ReadonlyArray<{
|
||||
readonly category:
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
| (string & {})
|
||||
readonly threshold:
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
| (string & {})
|
||||
}>
|
||||
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,12 +111,6 @@ const GeminiToolConfig = Schema.Struct({
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
thinkingLevel: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
})
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
@@ -150,10 +123,7 @@ const GeminiGenerationConfig = Schema.Struct({
|
||||
})
|
||||
|
||||
const GeminiBodyFields = {
|
||||
cachedContent: Schema.optional(Schema.String),
|
||||
contents: Schema.Array(GeminiContent),
|
||||
safetySettings: optionalArray(GeminiSafetySetting),
|
||||
serviceTier: Schema.optional(Schema.String),
|
||||
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
||||
tools: optionalArray(GeminiTool),
|
||||
toolConfig: Schema.optional(GeminiToolConfig),
|
||||
@@ -346,38 +316,17 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const input = request.providerOptions?.gemini
|
||||
const value = input?.thinkingConfig
|
||||
const value = request.providerOptions?.gemini?.thinkingConfig
|
||||
if (!ProviderShared.isRecord(value)) return {}
|
||||
const thinkingConfig = {
|
||||
thinkingBudget:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts:
|
||||
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||
? value.includeThoughts
|
||||
: ProviderShared.isRecord(value)
|
||||
? true
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
|
||||
}
|
||||
return {
|
||||
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSafetySettings(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const settings = value.flatMap((item) =>
|
||||
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||
? [{ category: item.category, threshold: item.threshold }]
|
||||
: [],
|
||||
)
|
||||
return settings
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const hasTools = request.tools.length > 0
|
||||
const generation = request.generation
|
||||
@@ -393,10 +342,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
}
|
||||
|
||||
return {
|
||||
cachedContent: options.cachedContent,
|
||||
contents: yield* lowerMessages(request),
|
||||
safetySettings: options.safetySettings,
|
||||
serviceTier: options.serviceTier,
|
||||
systemInstruction:
|
||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||
tools: hasTools
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type CacheHint,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -39,11 +38,6 @@ export const PATH = "/chat/completions"
|
||||
// The body schema is the provider-native JSON body. `fromRequest` below builds
|
||||
// this shape from the common `LLMRequest`, then `Route.make` validates and
|
||||
// JSON-encodes it before transport.
|
||||
const OpenAIChatCacheControl = Schema.Struct({
|
||||
type: Schema.Literal("ephemeral"),
|
||||
ttl: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatFunction = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
@@ -53,7 +47,6 @@ const OpenAIChatFunction = Schema.Struct({
|
||||
const OpenAIChatTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
function: OpenAIChatFunction,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
})
|
||||
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
|
||||
|
||||
@@ -68,11 +61,7 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
const OpenAIChatUserContent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("image_url"),
|
||||
image_url: Schema.Struct({ url: Schema.String }),
|
||||
@@ -80,10 +69,7 @@ const OpenAIChatUserContent = Schema.Union([
|
||||
])
|
||||
|
||||
const OpenAIChatMessage = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
@@ -97,16 +83,10 @@ const OpenAIChatMessage = Schema.Union([
|
||||
reasoning: Schema.optional(Schema.String),
|
||||
reasoning_text: Schema.optional(Schema.String),
|
||||
reasoning_details: Schema.optional(Schema.Unknown),
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("tool"),
|
||||
tool_call_id: Schema.String,
|
||||
content: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
||||
|
||||
@@ -127,7 +107,6 @@ export const bodyFields = {
|
||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
max_completion_tokens: Schema.optional(Schema.Number),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
@@ -230,20 +209,13 @@ export interface ParserState {
|
||||
// Lowering is the only place that knows how common LLM messages map onto the
|
||||
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
|
||||
// fields into `LLMRequest`.
|
||||
interface LoweringOptions {
|
||||
readonly cacheControl?: (
|
||||
cache: CacheHint | undefined,
|
||||
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||
},
|
||||
cache_control: options.cacheControl?.(tool.cache),
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -285,14 +257,11 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
}
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
|
||||
content.push({ type: "text", text: part.text })
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
@@ -301,18 +270,14 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
|
||||
}
|
||||
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
|
||||
return {
|
||||
role: "user" as const,
|
||||
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
|
||||
}
|
||||
if (content.every((part) => part.type === "text"))
|
||||
return { role: "user" as const, content: content.map((part) => part.text).join("") }
|
||||
return { role: "user" as const, content }
|
||||
})
|
||||
|
||||
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField?: string,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
@@ -350,44 +315,29 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
if (reasoning.length === 0) return nativeReasoning
|
||||
return text
|
||||
})()
|
||||
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||
const result = {
|
||||
role: "assistant" as const,
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_details: details,
|
||||
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
|
||||
}
|
||||
if (field === undefined || reasoningText === undefined) return result
|
||||
return { ...result, [field]: reasoningText }
|
||||
})
|
||||
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: part.id,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: part.id,
|
||||
content: text.join("\n"),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
images.push(
|
||||
...(yield* Effect.forEach(files, (item) =>
|
||||
@@ -401,29 +351,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField?: string,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
|
||||
return (yield* lowerToolMessages(message, options)).messages
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
|
||||
return (yield* lowerToolMessages(message)).messages
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const system: OpenAIChatMessage[] =
|
||||
request.system.length === 0
|
||||
? []
|
||||
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: request.system.map((part) => ({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
@@ -434,53 +370,28 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||
if (pendingImages.length > 0) {
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
...pendingImages.splice(0),
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||
],
|
||||
})
|
||||
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
|
||||
continue
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user" && typeof previous.content === "string")
|
||||
messages[messages.length - 1] = options.cacheControl?.(part.cache)
|
||||
? {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: previous.content },
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
|
||||
],
|
||||
}
|
||||
: { role: "user", content: `${previous.content}\n${part.text}` }
|
||||
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
|
||||
else if (previous?.role === "user" && Array.isArray(previous.content))
|
||||
messages[messages.length - 1] = {
|
||||
role: "user",
|
||||
content: [
|
||||
...previous.content,
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||
],
|
||||
content: [...previous.content, { type: "text", text: part.text }],
|
||||
}
|
||||
else
|
||||
messages.push(
|
||||
options.cacheControl?.(part.cache)
|
||||
? {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
|
||||
}
|
||||
: { role: "user", content: part.text },
|
||||
)
|
||||
else messages.push({ role: "user", content: part.text })
|
||||
continue
|
||||
}
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message, options)
|
||||
const lowered = yield* lowerToolMessages(message)
|
||||
messages.push(...lowered.messages)
|
||||
pendingImages.push(...lowered.images)
|
||||
continue
|
||||
}
|
||||
flushImages()
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
|
||||
}
|
||||
flushImages()
|
||||
return messages
|
||||
@@ -494,10 +405,7 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
request: LLMRequest,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||
// validation, and HTTP execution are composed by `Route.make`.
|
||||
const reasoningField = request.model.compatibility?.reasoningField
|
||||
@@ -507,26 +415,19 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
messages: yield* lowerMessages(request),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
options,
|
||||
),
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
...(maxTokensField === "max_completion_tokens"
|
||||
? { max_completion_tokens: generation?.maxTokens }
|
||||
: { max_tokens: generation?.maxTokens }),
|
||||
max_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
|
||||
@@ -22,8 +22,6 @@ const signRequest = (input: {
|
||||
readonly body: string
|
||||
readonly headers: Headers.Headers
|
||||
readonly credentials: Credentials
|
||||
readonly service: string
|
||||
readonly name: string
|
||||
}) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
@@ -36,26 +34,23 @@ const signRequest = (input: {
|
||||
accessKeyId: input.credentials.accessKeyId,
|
||||
secretAccessKey: input.credentials.secretAccessKey,
|
||||
sessionToken: input.credentials.sessionToken,
|
||||
service: input.service,
|
||||
service: "bedrock",
|
||||
}).sign()
|
||||
return Object.fromEntries(signed.headers.entries())
|
||||
},
|
||||
catch: (error) =>
|
||||
ProviderShared.invalidRequest(
|
||||
`${input.name} SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
})
|
||||
|
||||
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
|
||||
export const sigV4 = (
|
||||
credentials: Credentials | undefined,
|
||||
options: { readonly service?: string; readonly name?: string } = {},
|
||||
) =>
|
||||
export const sigV4 = (credentials: Credentials | undefined) =>
|
||||
Auth.custom((input: AuthInput) => {
|
||||
return Effect.gen(function* () {
|
||||
if (!credentials) {
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
|
||||
"Bedrock Converse requires either route bearer auth or AWS credentials configured on the route",
|
||||
)
|
||||
}
|
||||
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
|
||||
@@ -64,8 +59,6 @@ export const sigV4 = (
|
||||
body: input.body,
|
||||
headers: headersForSigning,
|
||||
credentials,
|
||||
service: options.service ?? "bedrock",
|
||||
name: options.name ?? "Bedrock Converse",
|
||||
})
|
||||
return Headers.setAll(headersForSigning, signed)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
/request_too_large/i,
|
||||
/input is too long for requested model/i,
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
@@ -32,6 +33,7 @@ const patterns = [
|
||||
/context window exceeds limit/i,
|
||||
/exceeded model token limit/i,
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/request entity too large/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
@@ -42,15 +44,11 @@ const patterns = [
|
||||
/token limit exceeded/i,
|
||||
]
|
||||
|
||||
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
@@ -102,8 +100,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
if (input.status === 413 || isPayloadTooLarge(text))
|
||||
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededReason(common)
|
||||
@@ -146,7 +142,12 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
)
|
||||
return new InvalidRequestReason(common)
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { Model, ProviderOptions } from "./schema"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
readonly context: number
|
||||
readonly input?: number
|
||||
readonly output: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { Auth } from "../route/auth"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { OpenAIChat } from "../protocols/openai-chat"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
export type Config = RouteDefaultsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly region?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly region?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
id: "bedrock-mantle-chat",
|
||||
provider: id,
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
|
||||
const region = input.region ?? input.credentials?.region ?? "us-east-1"
|
||||
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
|
||||
return route.with({
|
||||
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
|
||||
auth:
|
||||
input.apiKey === undefined
|
||||
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
|
||||
: Auth.bearer(input.apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
const defaults = (input: Config) => {
|
||||
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
|
||||
return rest
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (modelID: string | ModelID) =>
|
||||
configuredResponsesRoute
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
configuredChatRoute
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
|
||||
return {
|
||||
id,
|
||||
model: chat,
|
||||
chat,
|
||||
responses,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
}
|
||||
}
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const model = chatModel
|
||||
@@ -1,2 +0,0 @@
|
||||
export { chatModel as model } from "../amazon-bedrock-mantle"
|
||||
export type { Settings } from "../amazon-bedrock-mantle"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { chatModel as model } from "../../amazon-bedrock-mantle"
|
||||
export type { Settings } from "../../amazon-bedrock-mantle"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { responsesModel as model } from "../../amazon-bedrock-mantle"
|
||||
export type { Settings } from "../../amazon-bedrock-mantle"
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Anthropic from "./anthropic"
|
||||
export * as AnthropicCompatible from "./anthropic-compatible"
|
||||
export * as AmazonBedrock from "./amazon-bedrock"
|
||||
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
|
||||
export * as Azure from "./azure"
|
||||
export * as Cloudflare from "./cloudflare"
|
||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
|
||||
|
||||
@@ -4,72 +4,20 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
|
||||
import { isRecord } from "../protocols/shared"
|
||||
|
||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||
export const id = ProviderID.make(profile.provider)
|
||||
const ADAPTER = "openrouter"
|
||||
|
||||
type OpenRouterString<Known extends string> = Known | (string & {})
|
||||
|
||||
export interface OpenRouterProviderRouting {
|
||||
readonly [key: string]: unknown
|
||||
readonly order?: ReadonlyArray<string>
|
||||
readonly allow_fallbacks?: boolean
|
||||
readonly require_parameters?: boolean
|
||||
readonly data_collection?: OpenRouterString<"allow" | "deny">
|
||||
readonly only?: ReadonlyArray<string>
|
||||
readonly ignore?: ReadonlyArray<string>
|
||||
readonly quantizations?: ReadonlyArray<string>
|
||||
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
|
||||
readonly max_price?: Readonly<{
|
||||
prompt?: number | string
|
||||
completion?: number | string
|
||||
image?: number | string
|
||||
audio?: number | string
|
||||
request?: number | string
|
||||
}>
|
||||
readonly zdr?: boolean
|
||||
}
|
||||
|
||||
export type OpenRouterPlugin =
|
||||
| Readonly<{
|
||||
id: "web"
|
||||
max_results?: number
|
||||
search_prompt?: string
|
||||
engine?: OpenRouterString<"native" | "exa">
|
||||
}>
|
||||
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
|
||||
| Readonly<{ id: "moderation" }>
|
||||
| Readonly<{ id: "response-healing" }>
|
||||
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
|
||||
| Readonly<{ id: string & {}; [key: string]: unknown }>
|
||||
|
||||
export interface OpenRouterOptions {
|
||||
readonly [key: string]: unknown
|
||||
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
|
||||
readonly models?: ReadonlyArray<string>
|
||||
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
|
||||
readonly usage?: boolean | Record<string, unknown>
|
||||
readonly reasoning?: Record<string, unknown>
|
||||
readonly promptCacheKey?: string
|
||||
readonly provider?: OpenRouterProviderRouting
|
||||
readonly reasoning?: Readonly<{
|
||||
enabled?: boolean
|
||||
exclude?: boolean
|
||||
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
|
||||
max_tokens?: number
|
||||
}>
|
||||
readonly usage?: boolean | Readonly<{ include: boolean }>
|
||||
readonly user?: string
|
||||
readonly web_search_options?: Readonly<{
|
||||
max_results?: number
|
||||
search_prompt?: string
|
||||
engine?: OpenRouterString<"native" | "exa">
|
||||
}>
|
||||
}
|
||||
|
||||
export type OpenRouterProviderOptionsInput = ProviderOptions & {
|
||||
@@ -82,12 +30,6 @@ export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||
}
|
||||
|
||||
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
|
||||
Schema.Record(Schema.String, Schema.Any),
|
||||
])
|
||||
@@ -98,7 +40,7 @@ export const protocol = Protocol.make({
|
||||
body: {
|
||||
schema: OpenRouterBody,
|
||||
from: (request) =>
|
||||
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
|
||||
OpenAIChat.protocol.body.from(request).pipe(
|
||||
Effect.map((body) => {
|
||||
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
|
||||
let assistantIndex = 0
|
||||
@@ -129,39 +71,16 @@ export const protocol = Protocol.make({
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
const cacheControl = () => {
|
||||
const breakpoints = newBreakpoints(4)
|
||||
return (cache: CacheHint | undefined) => {
|
||||
if (cache === undefined || breakpoints.remaining === 0) return undefined
|
||||
breakpoints.remaining -= 1
|
||||
return {
|
||||
type: "ephemeral" as const,
|
||||
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyOptions = (input: unknown) => {
|
||||
const openrouter = isRecord(input) ? input : {}
|
||||
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
|
||||
openrouter
|
||||
return {
|
||||
...options,
|
||||
...(usage === undefined || usage === true
|
||||
...(openrouter.usage === true
|
||||
? { usage: { include: true } }
|
||||
: usage === false
|
||||
? { usage: { include: false } }
|
||||
: isRecord(usage)
|
||||
? { usage }
|
||||
: {}),
|
||||
...(Array.isArray(models) ? { models } : {}),
|
||||
...(isRecord(provider) ? { provider } : {}),
|
||||
...(Array.isArray(plugins) ? { plugins } : {}),
|
||||
...(isRecord(web_search_options) ? { web_search_options } : {}),
|
||||
...(isRecord(debug) ? { debug } : {}),
|
||||
...(typeof user === "string" ? { user } : {}),
|
||||
...(isRecord(reasoning) ? { reasoning } : {}),
|
||||
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
|
||||
: isRecord(openrouter.usage)
|
||||
? { usage: openrouter.usage }
|
||||
: {}),
|
||||
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
|
||||
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,15 +113,4 @@ export const configure = (input: ModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
export const model = provider.model
|
||||
|
||||
@@ -1,62 +1,31 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { XAIImages } from "../protocols/xai-images"
|
||||
import type { OpenAIOptionsInput } from "./openai-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
export type XAIProviderOptionsInput = ProviderOptions & {
|
||||
readonly xai?: OpenAIOptionsInput
|
||||
}
|
||||
|
||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images"
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: { providerOptions: { xai: { store: false } } },
|
||||
})
|
||||
|
||||
const chatRoute = Route.make({
|
||||
id: "openai-compatible-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAICompatibleChat.route.transport,
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
||||
|
||||
const configuredResponsesRoute = (input: ModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return responsesRoute.with({
|
||||
return OpenAIResponses.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
@@ -64,8 +33,9 @@ const configuredResponsesRoute = (input: ModelOptions) => {
|
||||
|
||||
const configuredChatRoute = (input: ModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return chatRoute.with({
|
||||
return OpenAICompatibleChat.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
@@ -74,8 +44,8 @@ const configuredChatRoute = (input: ModelOptions) => {
|
||||
export const configure = (input: ModelOptions = {}) => {
|
||||
const responsesRoute = configuredResponsesRoute(input)
|
||||
const chatRoute = configuredChatRoute(input)
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
XAIImages.model({
|
||||
id: modelID,
|
||||
@@ -95,15 +65,7 @@ export const configure = (input: ModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
export const model = provider.model
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||
import type { Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
@@ -46,11 +46,7 @@ export interface Route<Body, Prepared = unknown> {
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
|
||||
readonly prepareTransport: (
|
||||
body: Body,
|
||||
request: LLMRequest,
|
||||
options?: StreamOptions,
|
||||
) => Effect.Effect<Prepared, LLMError>
|
||||
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
|
||||
readonly streamPrepared: (
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
@@ -149,16 +145,12 @@ export interface Interface {
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly transform?: HttpRequestTransform
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
|
||||
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export interface GenerateMethod {
|
||||
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
|
||||
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
@@ -294,7 +286,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
|
||||
makeRouteModel<Options>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
prepareTransport: (body, request) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
request,
|
||||
@@ -302,7 +294,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
transform: options?.transform,
|
||||
}),
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||
const route = `${request.model.provider}/${request.model.route.id}`
|
||||
@@ -368,14 +359,14 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved, options)
|
||||
const prepared = yield* route.prepareTransport(body, resolved)
|
||||
|
||||
return {
|
||||
request: resolved,
|
||||
@@ -398,17 +389,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
|
||||
}
|
||||
})
|
||||
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request, options)
|
||||
const compiled = yield* compile(request)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
}),
|
||||
)
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
return yield* ProviderShared.eventError(
|
||||
@@ -417,24 +408,24 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
)
|
||||
})
|
||||
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
|
||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request, options)
|
||||
return (yield* Service).stream(request)
|
||||
}),
|
||||
) as Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
|
||||
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
|
||||
return Effect.gen(function* () {
|
||||
return yield* (yield* Service).generate(request, options)
|
||||
return yield* (yield* Service).generate(request)
|
||||
}) as Effect.Effect<LLMResponse, LLMError>
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
export const streamRequest = (request: LLMRequest) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request, options)
|
||||
return (yield* Service).stream(request)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export type {
|
||||
AnyRoute,
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
StreamOptions,
|
||||
} from "./client"
|
||||
export * from "./executor"
|
||||
export { Auth } from "./auth"
|
||||
@@ -23,4 +22,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
|
||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||
export type { Definition as FramingDef } from "./framing"
|
||||
export type { Protocol as ProtocolDef } from "./protocol"
|
||||
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
export type { Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
|
||||
@@ -28,9 +28,57 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
|
||||
"anthropic_version",
|
||||
"content",
|
||||
"contents",
|
||||
"frequencyPenalty",
|
||||
"frequency_penalty",
|
||||
"generationConfig",
|
||||
"inferenceConfig",
|
||||
"input",
|
||||
"maxTokens",
|
||||
"max_tokens",
|
||||
"messages",
|
||||
"model",
|
||||
"presencePenalty",
|
||||
"presence_penalty",
|
||||
"responseFormat",
|
||||
"response_format",
|
||||
"seed",
|
||||
"stop",
|
||||
"stopSequences",
|
||||
"stop_sequences",
|
||||
"stream",
|
||||
"streamOptions",
|
||||
"stream_options",
|
||||
"system",
|
||||
"systemInstruction",
|
||||
"system_instruction",
|
||||
"temperature",
|
||||
"thinking",
|
||||
"toolChoice",
|
||||
"toolConfig",
|
||||
"tool_choice",
|
||||
"tool_config",
|
||||
"tools",
|
||||
"topK",
|
||||
"topP",
|
||||
"top_k",
|
||||
"top_p",
|
||||
])
|
||||
|
||||
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
|
||||
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
|
||||
|
||||
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
|
||||
Effect.gen(function* () {
|
||||
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
|
||||
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
|
||||
if (forbiddenKeys.length > 0)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
|
||||
)
|
||||
if (ProviderShared.isRecord(body)) {
|
||||
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
|
||||
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
|
||||
@@ -72,19 +120,14 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
id: "http-json",
|
||||
with: (patch) => httpJson({ ...input, ...patch }),
|
||||
prepare: (prepareInput) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
||||
yield* (prepareInput.transform?.(request) ?? Effect.void)
|
||||
return {
|
||||
request: ProviderShared.jsonPost({
|
||||
url: request.url,
|
||||
body: request.body ?? "",
|
||||
headers: Headers.fromInput(request.headers),
|
||||
}),
|
||||
jsonRequestParts({
|
||||
...prepareInput,
|
||||
}).pipe(
|
||||
Effect.map((parts) => ({
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
framing: input.framing,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
|
||||
@@ -10,15 +10,6 @@ export interface TransportRuntime {
|
||||
readonly webSocket?: WebSocketExecutorInterface
|
||||
}
|
||||
|
||||
export interface HttpRequest {
|
||||
url: string
|
||||
readonly method: string
|
||||
headers: Record<string, string>
|
||||
body: string | undefined
|
||||
}
|
||||
|
||||
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
|
||||
|
||||
export interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
||||
@@ -36,7 +27,6 @@ export interface TransportPrepareInput<Body> {
|
||||
readonly auth: Auth.Definition
|
||||
readonly encodeBody: (body: Body) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transform?: HttpRequestTransform
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||
|
||||
@@ -124,7 +124,6 @@ export const ToolCallPart = Object.assign(
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.ToolCall" }),
|
||||
@@ -169,7 +168,6 @@ export const ReasoningPart = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
encrypted: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.Reasoning" })
|
||||
|
||||
@@ -123,7 +123,6 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
|
||||
|
||||
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
|
||||
context: Schema.optional(Schema.Number),
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
@@ -167,13 +166,9 @@ export namespace ModelDefaults {
|
||||
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
|
||||
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
|
||||
|
||||
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
|
||||
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
|
||||
|
||||
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
|
||||
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
|
||||
}) {}
|
||||
|
||||
export namespace ModelCompatibility {
|
||||
|
||||
@@ -137,61 +137,24 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("transforms the final HTTP request after serialization and authentication", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
transform: (request) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.headers.authorization).toBe("Bearer fresh-key")
|
||||
request.url = "https://proxy.test/v1/chat/completions"
|
||||
request.headers["x-plugin"] = "transformed"
|
||||
request.body = JSON.stringify({ transformed: true })
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
|
||||
expect(web.headers.get("x-plugin")).toBe("transformed")
|
||||
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
http: { body: { model: "gpt-5", messages: [], tools: [] } },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
http: { body: { model: "gpt-5", messages: [], tools: [] } },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
||||
|
||||
@@ -85,17 +85,14 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("classifies generic HTTP 413 payload errors", () =>
|
||||
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "payload-too-large",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ describe("public exports", () => {
|
||||
expect(CloudflareWorkersAI.configure).toBeFunction()
|
||||
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
|
||||
expect(OpenRouter.model).toBeFunction()
|
||||
expect(OpenRouter.provider.model).toBe(OpenRouter.model)
|
||||
expect(XAI.model).toBeFunction()
|
||||
expect(XAI.provider.model).toBe(XAI.model)
|
||||
expect(XAI.provider.responses).toBe(XAI.responses)
|
||||
expect(XAI.provider.chat).toBe(XAI.chat)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
const messages = [
|
||||
"tokens in request more than max tokens allowed",
|
||||
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
@@ -18,24 +19,6 @@ describe("provider error classification", () => {
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
classifyProviderFailure({
|
||||
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
status: 400,
|
||||
}),
|
||||
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
|
||||
]
|
||||
|
||||
expect(failures).toEqual(
|
||||
failures.map((failure) =>
|
||||
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
|
||||
),
|
||||
)
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
const messages = [
|
||||
"Throttling error: Too many tokens, please wait before trying again.",
|
||||
@@ -76,10 +59,9 @@ describe("provider error classification", () => {
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
"ProviderInternal",
|
||||
])
|
||||
expect(
|
||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -9,39 +9,9 @@ LLM.request({
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
// @ts-expect-error Gemini safety settings require a threshold for every category.
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
|
||||
serviceTier: "future-tier",
|
||||
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Gemini thinking budgets must be numeric.
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
|
||||
})
|
||||
|
||||
@@ -5,28 +5,6 @@ const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
models: ["google/gemini-3.1-pro"],
|
||||
provider: {
|
||||
order: ["anthropic"],
|
||||
require_parameters: true,
|
||||
data_collection: "future-policy",
|
||||
sort: "future-sort",
|
||||
max_price: { prompt: "0.50" },
|
||||
},
|
||||
reasoning: { effort: "future-effort", exclude: false },
|
||||
plugins: [{ id: "future-plugin", enabled: true }],
|
||||
web_search_options: { engine: "future-engine" },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
|
||||
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
|
||||
|
||||
const model = XAI.provider.model("grok-4")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
|
||||
providerOptions: { xai: { reasoningEffort: true } },
|
||||
providerOptions: { openai: { reasoningEffort: true } },
|
||||
})
|
||||
|
||||
@@ -21,47 +21,12 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/google-vertex/chat"),
|
||||
import("@opencode-ai/ai/providers/google-vertex/responses"),
|
||||
import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
import("@opencode-ai/ai/providers/openrouter"),
|
||||
import("@opencode-ai/ai/providers/xai"),
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[8].model).toBe(modules[9].model)
|
||||
expect(modules[12].model).toBe(modules[13].model)
|
||||
expect(modules[19].model).toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps OpenRouter and xAI package settings onto executable models", async () => {
|
||||
const OpenRouter = await import("@opencode-ai/ai/providers/openrouter")
|
||||
const XAI = await import("@opencode-ai/ai/providers/xai")
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://provider.example.test/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { openrouter: { usage: true } },
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { xai: { reasoningEffort: "high" } },
|
||||
})
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
expect(selected.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(selected.route.defaults.http?.body).toEqual(settings.body)
|
||||
expect(selected.route.defaults.limits).toEqual(settings.limits)
|
||||
}
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
|
||||
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM } from "../../src"
|
||||
import { AmazonBedrockMantle } from "../../src/providers"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const credentials = {
|
||||
region: "us-east-2",
|
||||
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
}
|
||||
|
||||
describe("Amazon Bedrock Mantle provider", () => {
|
||||
it.effect("uses Chat by default and exposes Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = AmazonBedrockMantle.configure({ credentials })
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
)
|
||||
|
||||
expect(chat).toMatchObject({
|
||||
route: "bedrock-mantle-chat",
|
||||
protocol: "openai-chat",
|
||||
body: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
expect(responses).toMatchObject({
|
||||
route: "bedrock-mantle-responses",
|
||||
protocol: "openai-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the Mantle endpoint and signing service", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
const model = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" }).responses("openai.gpt-oss-120b")
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(seen[0]?.url).toBe("https://bedrock-mantle.us-west-1.api.aws/v1/responses")
|
||||
expect(seen[0]?.authorization).toContain("/us-west-1/bedrock-mantle/aws4_request")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("supports bearer authentication and custom base URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
const model = AmazonBedrockMantle.configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://mantle.test/v1",
|
||||
}).chat("openai.gpt-oss-safeguard-20b")
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request)
|
||||
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
|
||||
return input.respond("", { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-mantle",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "openai-responses",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
metadata: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
|
||||
describe("Amazon Bedrock Mantle recorded", () => {
|
||||
recorded.effect("streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: AmazonBedrockMantle.configure({
|
||||
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
|
||||
region: "us-east-1",
|
||||
}).responses("openai.gpt-oss-120b"),
|
||||
prompt: "Reply with exactly: hello",
|
||||
generation: { maxTokens: 256, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.trim().toLowerCase()).toBe("hello")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -41,14 +41,7 @@ describe("Gemini route", () => {
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
|
||||
serviceTier: "priority",
|
||||
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
|
||||
},
|
||||
},
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
|
||||
}),
|
||||
)
|
||||
const filtered = yield* compileRequest(
|
||||
@@ -56,33 +49,12 @@ describe("Gemini route", () => {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
|
||||
}),
|
||||
)
|
||||
const defaulted = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
}),
|
||||
)
|
||||
const emptySafetySettings = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { safetySettings: [] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
})
|
||||
expect(prepared.body.cachedContent).toBe("cachedContents/example")
|
||||
expect(prepared.body.safetySettings).toEqual([
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
|
||||
])
|
||||
expect(prepared.body.serviceTier).toBe("priority")
|
||||
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
|
||||
expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "high",
|
||||
})
|
||||
expect(emptySafetySettings.body.safetySettings).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -181,6 +181,23 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("protects the Vertex Messages API version from body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
http: { body: { anthropic_version: "wrong" } },
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
|
||||
@@ -144,20 +144,6 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("configures the max tokens request field", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = OpenAICompatibleChat.route
|
||||
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
|
||||
expect(prepared.body).not.toHaveProperty("max_tokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches AI SDK compatible tool request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../../src"
|
||||
import { LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
@@ -27,131 +27,10 @@ describe("OpenRouter", () => {
|
||||
model: "openai/gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say hello." }],
|
||||
stream: true,
|
||||
usage: { include: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
system: [
|
||||
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
cache: { tools: true, system: true, messages: { tail: 1 } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers manual assistant and tool-result cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user("Call the tool"),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: "Done",
|
||||
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: "Call the tool" },
|
||||
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
|
||||
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("caps manual cache controls at four breakpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
const system = prepared.body.messages[0]
|
||||
expect(system?.role).toBe("system")
|
||||
expect(
|
||||
system && Array.isArray(system.content)
|
||||
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
|
||||
: [],
|
||||
).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: { messages: "latest-assistant" },
|
||||
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: "Think" },
|
||||
{ role: "assistant", cache_control: { type: "ephemeral" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows usage accounting to be disabled explicitly", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({
|
||||
apiKey: "test-key",
|
||||
providerOptions: { openrouter: { usage: false } },
|
||||
}).model("openai/gpt-4o-mini"),
|
||||
cache: "none",
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.usage).toEqual({ include: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies OpenRouter payload options from the model helper", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -163,13 +42,6 @@ describe("OpenRouter", () => {
|
||||
usage: true,
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
||||
provider: { order: ["anthropic", "google"], require_parameters: true },
|
||||
plugins: [{ id: "response-healing" }],
|
||||
web_search_options: { engine: "native", max_results: 3 },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
future_option: { enabled: true },
|
||||
},
|
||||
},
|
||||
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
||||
@@ -181,54 +53,10 @@ describe("OpenRouter", () => {
|
||||
usage: { include: true },
|
||||
reasoning: { effort: "high" },
|
||||
prompt_cache_key: "session_123",
|
||||
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
||||
provider: { order: ["anthropic", "google"], require_parameters: true },
|
||||
plugins: [{ id: "response-healing" }],
|
||||
web_search_options: { engine: "native", max_results: 3 },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
future_option: { enabled: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
const invalid: Record<string, unknown> = {
|
||||
usage: "yes",
|
||||
models: "anthropic/claude-sonnet-4.6",
|
||||
provider: [],
|
||||
plugins: {},
|
||||
web_search_options: [],
|
||||
debug: [],
|
||||
user: 123,
|
||||
reasoning: [],
|
||||
promptCacheKey: 123,
|
||||
future_option: { enabled: true },
|
||||
}
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({
|
||||
apiKey: "test-key",
|
||||
providerOptions: { openrouter: invalid },
|
||||
}).model("openai/gpt-4o-mini"),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
|
||||
expect(prepared.body).not.toHaveProperty("usage")
|
||||
expect(prepared.body).not.toHaveProperty("models")
|
||||
expect(prepared.body).not.toHaveProperty("provider")
|
||||
expect(prepared.body).not.toHaveProperty("plugins")
|
||||
expect(prepared.body).not.toHaveProperty("web_search_options")
|
||||
expect(prepared.body).not.toHaveProperty("debug")
|
||||
expect(prepared.body).not.toHaveProperty("user")
|
||||
expect(prepared.body).not.toHaveProperty("reasoning")
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the upstream provider finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
|
||||
@@ -276,7 +104,6 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
@@ -310,7 +137,6 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
@@ -336,7 +162,6 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
@@ -358,7 +183,6 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
@@ -55,10 +54,7 @@ export function TabNavItem(props: {
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session()
|
||||
return session ? displayLabel(session) : props.fallbackTitle
|
||||
})
|
||||
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session()
|
||||
@@ -147,7 +143,7 @@ export function TabNavItem(props: {
|
||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
titleEl.textContent = session.title ?? ""
|
||||
titleEl.textContent = session.title
|
||||
setEditing(true)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -306,7 +302,7 @@ export function TabNavItem(props: {
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: title(),
|
||||
title: props.session()?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
|
||||
@@ -129,8 +129,8 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
api.list().then((projects) => {
|
||||
return projects
|
||||
.filter((p) => !!p?.id)
|
||||
.map(normalizeProjectInfo)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.map(normalizeProjectInfo)
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
|
||||
@@ -168,7 +168,6 @@ export function sanitizeProject(project: Project) {
|
||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||
return {
|
||||
...project,
|
||||
worktree: "canonical" in project ? project.canonical : project.worktree,
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { serverName } from "@/context/server"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "./home-controller"
|
||||
@@ -24,7 +23,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
if (!value) return []
|
||||
return sessions.data
|
||||
.searchRecords()
|
||||
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
|
||||
})
|
||||
const active = createMemo(() => {
|
||||
const records = results()
|
||||
|
||||
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
|
||||
selected: boolean
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => displayLabel(props.record.session))
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => displayLabel(props.record.session))
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||
|
||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
}): JSX.Element => {
|
||||
const title = () => displayLabel(props.session)
|
||||
const title = () => sessionTitle(props.session.title)
|
||||
|
||||
return (
|
||||
<A
|
||||
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={displayLabel(props.session)}
|
||||
value={sessionTitle(props.session.title)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
|
||||
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
@@ -296,11 +296,8 @@ export function MessageTimeline(props: {
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const titleLabel = createMemo(() => {
|
||||
const session = info()
|
||||
if (!session) return
|
||||
return displayLabel(session)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
@@ -314,10 +311,7 @@ export function MessageTimeline(props: {
|
||||
if (!id) return emptyMessages
|
||||
return sync().data.message[id] ?? emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(() => {
|
||||
const session = parent()
|
||||
return session ? displayLabel(session) : language.t("command.session.new")
|
||||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
@@ -335,7 +329,7 @@ export function MessageTimeline(props: {
|
||||
if (value) return value
|
||||
return language.t("command.session.new")
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
@@ -918,10 +912,9 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(() => {
|
||||
const session = sync().session.get(props.sessionID)
|
||||
return session ? displayLabel(session) : language.t("command.session.new")
|
||||
})
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
|
||||
@@ -38,19 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
instance.scrollOffset = 79_400
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
instance.scrollOffset = 0
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
@@ -128,7 +128,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
||||
location: {
|
||||
directory: directory(value) ?? "",
|
||||
project: { id: "", directory: directory(value) ?? "", canonical: directory(value) ?? "" },
|
||||
project: { id: "", directory: directory(value) ?? "" },
|
||||
},
|
||||
data,
|
||||
})
|
||||
@@ -298,19 +298,12 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
project: {
|
||||
...input.current.project,
|
||||
async list() {
|
||||
return ((await legacy().project.list()).data ?? []).map((project) => ({
|
||||
...project,
|
||||
canonical: project.worktree,
|
||||
}))
|
||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
||||
},
|
||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
||||
const result = await legacy(value?.location).project.current()
|
||||
if (!result.data) throw new Error("Project not found")
|
||||
return {
|
||||
id: result.data.id,
|
||||
directory: result.data.worktree,
|
||||
canonical: result.data.worktree,
|
||||
} satisfies ProjectCurrent
|
||||
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
||||
},
|
||||
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
||||
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
export function sessionTitle(title?: string) {
|
||||
if (!title) return title
|
||||
const match = title.match(pattern)
|
||||
return match?.[1] ?? title
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
if (!("location" in input)) return input
|
||||
@@ -14,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
parentID: input.parentID,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
title: withTimestampedFallback(input),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
version: "",
|
||||
|
||||
@@ -40,9 +40,6 @@
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
|
||||
@@ -62,8 +62,8 @@ for (const target of targets) {
|
||||
await rm("dist-node", { recursive: true, force: true })
|
||||
const assetHash = await hashNodeAssets(assets)
|
||||
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
await copyNodeAssets(assets)
|
||||
|
||||
const host = target.platform === process.platform && target.arch === process.arch
|
||||
if (host) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -43,10 +43,6 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
},
|
||||
...Object.values(shellParserWasmAssets).map((key) => ({
|
||||
key,
|
||||
source: fileURLToPath(import.meta.resolve(key)),
|
||||
})),
|
||||
...attentionSoundAssets.map((key) => ({
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
@@ -58,9 +54,8 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
source: path.join(ptyRoot, relative),
|
||||
})),
|
||||
]
|
||||
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
|
||||
await Promise.all(unique.map((asset) => stat(asset.source)))
|
||||
return unique
|
||||
await Promise.all(assets.map((asset) => stat(asset.source)))
|
||||
return assets
|
||||
}
|
||||
|
||||
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type SessionMessageInfo,
|
||||
type SkillInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
@@ -214,7 +213,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
sessions: page.data.map((session) => ({
|
||||
sessionId: session.id,
|
||||
cwd: session.location.directory,
|
||||
title: withTimestampedFallback(session),
|
||||
title: session.title,
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
})),
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
|
||||
@@ -79,6 +79,9 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "Log out of OpenCode Console",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
@@ -133,6 +136,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "Manage plugins",
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
const integrationID = "opencode"
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.console.commands.logout,
|
||||
Effect.fn("cli.console.logout")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const found = yield* Effect.promise(() => client.integration.get({ integrationID, location }))
|
||||
const credentials = found.data?.connections.filter((connection) => connection.type === "credential") ?? []
|
||||
|
||||
if (credentials.length === 0) {
|
||||
process.stdout.write("Not logged in" + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
|
||||
{ discard: true },
|
||||
)
|
||||
process.stdout.write("Logged out from OpenCode Console" + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -45,11 +45,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
const service = server.service
|
||||
yield* run({
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
|
||||
},
|
||||
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
|
||||
server: {
|
||||
endpoint: server.endpoint,
|
||||
service: service
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
|
||||
@@ -116,6 +116,13 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
||||
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: {
|
||||
hints: {
|
||||
onboarding: !kv.dismissed_getting_started,
|
||||
},
|
||||
}),
|
||||
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
},
|
||||
console: {
|
||||
login: () => import("./commands/handlers/console/login"),
|
||||
logout: () => import("./commands/handlers/console/logout"),
|
||||
},
|
||||
mcp: {
|
||||
list: () => import("./commands/handlers/mcp/list"),
|
||||
@@ -35,6 +36,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
},
|
||||
migrate: () => import("./commands/handlers/migrate"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
|
||||
@@ -29,11 +29,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
}
|
||||
|
||||
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
||||
export const shellParserWasmAssets = {
|
||||
runtime: "web-tree-sitter/tree-sitter.wasm",
|
||||
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
|
||||
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
|
||||
} as const
|
||||
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
||||
|
||||
export const attentionSoundAssets = [
|
||||
|
||||
@@ -235,6 +235,10 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
|
||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
|
||||
method: "POST",
|
||||
path: "/api/session/ses_loaded/fork",
|
||||
query: {},
|
||||
body: { boundary: { type: "through" } },
|
||||
body: {},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
terminal: { title: false },
|
||||
prompt: { editor: false, paste: "full" },
|
||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||
hints: { onboarding: false },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect(config).not.toHaveProperty("hints")
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
|
||||
const cleanup: Array<() => Promise<void>> = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanup.splice(0).map((fn) => fn()))
|
||||
})
|
||||
|
||||
async function cli(args: string[], env?: Record<string, string>) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
env: { ...process.env, ...env },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
|
||||
test("registers the console logout command", async () => {
|
||||
const result = await cli(["console", "--help"])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("logout Log out of OpenCode Console")
|
||||
})
|
||||
|
||||
test("removes stored OpenCode Console credentials", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-console-logout-"))
|
||||
const removed: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/api/integration/opencode") {
|
||||
return Response.json({
|
||||
location: { directory: process.cwd(), project: { id: "global", directory: "/" } },
|
||||
data: {
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
methods: [],
|
||||
connections: removed.length === 0 ? [{ type: "credential", id: "cred_test", label: "default" }] : [],
|
||||
},
|
||||
})
|
||||
}
|
||||
if (request.method === "DELETE" && url.pathname === "/api/credential/cred_test") {
|
||||
removed.push(url.pathname)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
},
|
||||
})
|
||||
cleanup.push(async () => {
|
||||
server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
})
|
||||
await fs.mkdir(path.join(root, "state", "opencode"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(root, "state", "opencode", "service-local.json"),
|
||||
JSON.stringify({ version: OPENCODE_VERSION, url: server.url.toString(), pid: process.pid }),
|
||||
)
|
||||
const env = {
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
}
|
||||
|
||||
const result = await cli(["console", "logout"], env)
|
||||
|
||||
expect(result).toMatchObject({ exitCode: 0, stdout: "Logged out from OpenCode Console\n" })
|
||||
expect(removed).toEqual(["/api/credential/cred_test"])
|
||||
|
||||
const repeated = await cli(["console", "logout"], env)
|
||||
expect(repeated).toMatchObject({ exitCode: 0, stdout: "Not logged in\n" })
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { collectNodeAssets } from "../script/node-assets"
|
||||
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
|
||||
|
||||
test("collects each SEA asset key once", async () => {
|
||||
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } fro
|
||||
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
|
||||
|
||||
function location(directory: string, workspaceID?: string): LocationGetOutput {
|
||||
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
|
||||
return { directory, workspaceID, project: { id: "project", directory } }
|
||||
}
|
||||
|
||||
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
|
||||
|
||||
@@ -3,13 +3,7 @@ import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import {
|
||||
nodeExecArgv,
|
||||
nodeTarget,
|
||||
type NodeTarget,
|
||||
photonWasmAsset,
|
||||
shellParserWasmAssets,
|
||||
} from "./src/node/target"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
|
||||
|
||||
const dir = import.meta.dirname
|
||||
|
||||
@@ -200,9 +194,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
|
||||
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
try {
|
||||
|
||||
@@ -118,7 +118,6 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
|
||||
|
||||
export type Endpoint5_1Input = {
|
||||
readonly id?: Session.ID | undefined
|
||||
readonly title?: string | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly location?: Location.Ref | undefined
|
||||
@@ -398,7 +397,7 @@ export type Endpoint5_26Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -524,7 +523,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
@@ -686,7 +685,7 @@ export type Endpoint5_26Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly content?:
|
||||
| readonly [
|
||||
(
|
||||
@@ -726,7 +725,7 @@ export type Endpoint5_26Output =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -776,7 +775,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,13 +299,7 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
|
||||
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
|
||||
preserveEffect<Endpoint5_1Output>()(
|
||||
raw["session.create"]({
|
||||
payload: {
|
||||
id: input?.["id"],
|
||||
title: input?.["title"],
|
||||
agent: input?.["agent"],
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
},
|
||||
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
|
||||
@@ -462,7 +462,6 @@ export function make(options: ClientOptions) {
|
||||
path: `/api/session`,
|
||||
body: {
|
||||
id: input?.["id"],
|
||||
title: input?.["title"],
|
||||
agent: input?.["agent"],
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
|
||||
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||
export type SessionStructuredError = { type: string; message: string }
|
||||
|
||||
export type SessionMessageCompactionRunning = {
|
||||
type: "compaction"
|
||||
@@ -279,7 +279,7 @@ export type ProjectCommands = { start?: string }
|
||||
|
||||
export type ProjectTime = { created: number; updated: number; initialized?: number }
|
||||
|
||||
export type ProjectCurrent = { id: string; directory: string; canonical: string }
|
||||
export type ProjectCurrent = { id: string; directory: string }
|
||||
|
||||
export type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
@@ -1430,7 +1430,7 @@ export type McpResourceCatalog = { resources: Array<McpResource>; templates: Arr
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
canonical: string
|
||||
worktree: string
|
||||
vcs?: ProjectVcs
|
||||
name?: string
|
||||
icon?: ProjectIcon
|
||||
@@ -1757,7 +1757,7 @@ export type SessionInfo = {
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
time: { created: number; updated: number; archived?: number }
|
||||
title?: string
|
||||
title: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
revert?: SessionRevert
|
||||
@@ -2006,7 +2006,7 @@ export type SessionV1Info = {
|
||||
cost?: number
|
||||
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||
share?: { url: string }
|
||||
title?: string
|
||||
title: string
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
version: string
|
||||
@@ -2515,11 +2515,7 @@ export type LocationGetInput = {
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type LocationGetOutput = {
|
||||
directory: string
|
||||
workspaceID?: string
|
||||
project: { id: string; directory: string; canonical: string }
|
||||
}
|
||||
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
|
||||
export type AgentListInput = {
|
||||
readonly location?: {
|
||||
@@ -2528,7 +2524,7 @@ export type AgentListInput = {
|
||||
}
|
||||
|
||||
export type AgentListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<AgentInfo>
|
||||
}
|
||||
|
||||
@@ -2540,7 +2536,7 @@ export type AgentGetInput = {
|
||||
}
|
||||
|
||||
export type AgentGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: AgentInfo
|
||||
}
|
||||
|
||||
@@ -2551,7 +2547,7 @@ export type PluginListInput = {
|
||||
}
|
||||
|
||||
export type PluginListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
@@ -2662,35 +2658,24 @@ export type SessionListOutput = SessionsResponse
|
||||
export type SessionCreateInput = {
|
||||
readonly id?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["id"]
|
||||
readonly title?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["title"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["model"]
|
||||
readonly location?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
@@ -3246,7 +3231,7 @@ export type ModelListInput = {
|
||||
}
|
||||
|
||||
export type ModelListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ModelInfo>
|
||||
}
|
||||
|
||||
@@ -3257,7 +3242,7 @@ export type ModelDefaultInput = {
|
||||
}
|
||||
|
||||
export type ModelDefaultOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ModelInfo | null
|
||||
}
|
||||
|
||||
@@ -3284,7 +3269,7 @@ export type ProviderListInput = {
|
||||
}
|
||||
|
||||
export type ProviderListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ProviderInfo>
|
||||
}
|
||||
|
||||
@@ -3296,7 +3281,7 @@ export type ProviderGetInput = {
|
||||
}
|
||||
|
||||
export type ProviderGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ProviderInfo
|
||||
}
|
||||
|
||||
@@ -3307,7 +3292,7 @@ export type IntegrationListInput = {
|
||||
}
|
||||
|
||||
export type IntegrationListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<IntegrationInfo>
|
||||
}
|
||||
|
||||
@@ -3319,7 +3304,7 @@ export type IntegrationGetInput = {
|
||||
}
|
||||
|
||||
export type IntegrationGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationInfo | null
|
||||
}
|
||||
|
||||
@@ -3366,7 +3351,7 @@ export type IntegrationOauthConnectInput = {
|
||||
}
|
||||
|
||||
export type IntegrationOauthConnectOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: {
|
||||
attemptID: string
|
||||
url: string
|
||||
@@ -3385,7 +3370,7 @@ export type IntegrationOauthStatusInput = {
|
||||
}
|
||||
|
||||
export type IntegrationOauthStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationAttemptStatus
|
||||
}
|
||||
|
||||
@@ -3420,7 +3405,7 @@ export type IntegrationCommandConnectInput = {
|
||||
}
|
||||
|
||||
export type IntegrationCommandConnectOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationCommandAttempt
|
||||
}
|
||||
|
||||
@@ -3433,7 +3418,7 @@ export type IntegrationCommandStatusInput = {
|
||||
}
|
||||
|
||||
export type IntegrationCommandStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: IntegrationCommandAttemptStatus
|
||||
}
|
||||
|
||||
@@ -3454,7 +3439,7 @@ export type McpListInput = {
|
||||
}
|
||||
|
||||
export type McpListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<McpServer>
|
||||
}
|
||||
|
||||
@@ -3543,7 +3528,7 @@ export type McpResourceCatalogInput = {
|
||||
}
|
||||
|
||||
export type McpResourceCatalogOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: McpResourceCatalog
|
||||
}
|
||||
|
||||
@@ -3592,7 +3577,7 @@ export type FormRequestListInput = {
|
||||
}
|
||||
|
||||
export type FormRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<FormInfo>
|
||||
}
|
||||
|
||||
@@ -4448,7 +4433,7 @@ export type PermissionRequestListInput = {
|
||||
}
|
||||
|
||||
export type PermissionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<PermissionRequest>
|
||||
}
|
||||
|
||||
@@ -4570,7 +4555,7 @@ export type FileListInput = {
|
||||
}
|
||||
|
||||
export type FileListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<FileSystemEntry>
|
||||
}
|
||||
|
||||
@@ -4602,7 +4587,7 @@ export type FileFindInput = {
|
||||
}
|
||||
|
||||
export type FileFindOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<FileSystemEntry>
|
||||
}
|
||||
|
||||
@@ -4613,7 +4598,7 @@ export type CommandListInput = {
|
||||
}
|
||||
|
||||
export type CommandListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<CommandInfo>
|
||||
}
|
||||
|
||||
@@ -4624,7 +4609,7 @@ export type SkillListInput = {
|
||||
}
|
||||
|
||||
export type SkillListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<SkillInfo>
|
||||
}
|
||||
|
||||
@@ -4637,7 +4622,7 @@ export type PtyListInput = {
|
||||
}
|
||||
|
||||
export type PtyListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<Pty>
|
||||
}
|
||||
|
||||
@@ -4683,7 +4668,7 @@ export type PtyCreateInput = {
|
||||
}
|
||||
|
||||
export type PtyCreateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
@@ -4695,7 +4680,7 @@ export type PtyGetInput = {
|
||||
}
|
||||
|
||||
export type PtyGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
@@ -4712,7 +4697,7 @@ export type PtyUpdateInput = {
|
||||
}
|
||||
|
||||
export type PtyUpdateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Pty
|
||||
}
|
||||
|
||||
@@ -4732,7 +4717,7 @@ export type ShellListInput = {
|
||||
}
|
||||
|
||||
export type ShellListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ShellInfo1>
|
||||
}
|
||||
|
||||
@@ -4767,7 +4752,7 @@ export type ShellCreateInput = {
|
||||
}
|
||||
|
||||
export type ShellCreateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
@@ -4779,7 +4764,7 @@ export type ShellGetInput = {
|
||||
}
|
||||
|
||||
export type ShellGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
@@ -4792,7 +4777,7 @@ export type ShellTimeoutInput = {
|
||||
}
|
||||
|
||||
export type ShellTimeoutOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ShellInfo1
|
||||
}
|
||||
|
||||
@@ -4816,7 +4801,7 @@ export type ShellOutputInput = {
|
||||
}
|
||||
|
||||
export type ShellOutputOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
|
||||
@@ -4836,7 +4821,7 @@ export type QuestionRequestListInput = {
|
||||
}
|
||||
|
||||
export type QuestionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<QuestionRequest>
|
||||
}
|
||||
|
||||
@@ -4866,7 +4851,7 @@ export type ReferenceListInput = {
|
||||
}
|
||||
|
||||
export type ReferenceListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
@@ -4909,7 +4894,7 @@ export type VcsStatusInput = {
|
||||
}
|
||||
|
||||
export type VcsStatusOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<VcsFileStatus>
|
||||
}
|
||||
|
||||
@@ -4932,7 +4917,7 @@ export type VcsDiffInput = {
|
||||
}
|
||||
|
||||
export type VcsDiffOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<FileDiffInfo>
|
||||
}
|
||||
|
||||
@@ -4953,7 +4938,7 @@ export type WebsearchProvidersInput = {
|
||||
}
|
||||
|
||||
export type WebsearchProvidersOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<WebSearchProvider>
|
||||
}
|
||||
|
||||
@@ -4966,6 +4951,6 @@ export type WebsearchQueryInput = {
|
||||
}
|
||||
|
||||
export type WebsearchQueryOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: { providerID: string; results: Array<WebSearchResult> }
|
||||
}
|
||||
|
||||
@@ -41,11 +41,6 @@
|
||||
"node": "./src/image/photon-wasm.node.ts",
|
||||
"default": "./src/image/photon-wasm.bun.ts"
|
||||
},
|
||||
"#shell-parser-wasm": {
|
||||
"bun": "./src/shell/parser-wasm.bun.ts",
|
||||
"node": "./src/shell/parser-wasm.node.ts",
|
||||
"default": "./src/shell/parser-wasm.bun.ts"
|
||||
},
|
||||
"#process-lock-ffi": {
|
||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||
"node": "./src/util/process-lock-ffi.node.ts",
|
||||
@@ -56,6 +51,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
@@ -99,6 +95,7 @@
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -120,11 +117,9 @@
|
||||
"immer": "11.1.4",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
|
||||
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
|
||||
"prevIds": [
|
||||
"db37a97f-9b5e-4c87-be8b-4feace35136c"
|
||||
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
@@ -1302,7 +1302,7 @@
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
export * as AISDKNative from "./aisdk-native"
|
||||
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export interface Mapping {
|
||||
readonly package: string
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export function map(
|
||||
packageName: string | undefined,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
modelID?: string,
|
||||
): Mapping | undefined {
|
||||
const baseSettings = mapBaseSettings(settings)
|
||||
switch (packageName) {
|
||||
case "@ai-sdk/amazon-bedrock/mantle":
|
||||
return mapBedrockMantle(settings, baseSettings, modelID)
|
||||
case "@ai-sdk/google":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/google",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapGoogleOptions(settings),
|
||||
},
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapXAIOptions(settings),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockMantle(
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
baseSettings: Readonly<Record<string, unknown>>,
|
||||
modelID: string | undefined,
|
||||
): Mapping | undefined {
|
||||
const apiKey =
|
||||
typeof settings.apiKey === "string"
|
||||
? settings.apiKey
|
||||
: typeof settings.bearerToken === "string"
|
||||
? settings.bearerToken
|
||||
: undefined
|
||||
const credentials = mapBedrockCredentials(settings)
|
||||
if (apiKey === undefined && credentials === undefined) return undefined
|
||||
const safeguard = modelID === "openai.gpt-oss-safeguard-20b" || modelID === "openai.gpt-oss-safeguard-120b"
|
||||
return {
|
||||
package: `@opencode-ai/ai/providers/amazon-bedrock/mantle/${safeguard ? "chat" : "responses"}`,
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
...(apiKey === undefined ? {} : { apiKey }),
|
||||
...(credentials === undefined ? {} : { credentials }),
|
||||
...(typeof settings.region === "string" ? { region: settings.region } : {}),
|
||||
...mapOpenAIOptions(settings),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
|
||||
if (
|
||||
isRecord(settings.credentials) &&
|
||||
typeof settings.credentials.region === "string" &&
|
||||
typeof settings.credentials.accessKeyId === "string" &&
|
||||
typeof settings.credentials.secretAccessKey === "string"
|
||||
) {
|
||||
return {
|
||||
region: settings.credentials.region,
|
||||
accessKeyId: settings.credentials.accessKeyId,
|
||||
secretAccessKey: settings.credentials.secretAccessKey,
|
||||
...(typeof settings.credentials.sessionToken === "string"
|
||||
? { sessionToken: settings.credentials.sessionToken }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof settings.region !== "string" ||
|
||||
typeof settings.accessKeyId !== "string" ||
|
||||
typeof settings.secretAccessKey !== "string"
|
||||
)
|
||||
return undefined
|
||||
return {
|
||||
region: settings.region,
|
||||
accessKeyId: settings.accessKeyId,
|
||||
secretAccessKey: settings.secretAccessKey,
|
||||
...(typeof settings.sessionToken === "string" ? { sessionToken: settings.sessionToken } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
|
||||
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
|
||||
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { openai: options } }
|
||||
}
|
||||
|
||||
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
|
||||
return {
|
||||
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
...(isRecord(input) && typeof input.includeThoughts === "boolean"
|
||||
? { includeThoughts: input.includeThoughts }
|
||||
: {}),
|
||||
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
|
||||
}
|
||||
const options = {
|
||||
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { gemini: options } }
|
||||
}
|
||||
|
||||
function mapOpenRouter(
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
baseSettings: Readonly<Record<string, unknown>>,
|
||||
): Mapping {
|
||||
const headers =
|
||||
Provider.mergeHeaders(
|
||||
{
|
||||
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
|
||||
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
|
||||
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
|
||||
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
|
||||
: {}),
|
||||
},
|
||||
isStringRecord(settings.headers) ? settings.headers : undefined,
|
||||
) ?? {}
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openrouter",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapOpenRouterOptions(settings),
|
||||
},
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"timeout",
|
||||
].includes(key),
|
||||
),
|
||||
)
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { openrouter: options } }
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { xai: options } }
|
||||
}
|
||||
@@ -299,6 +299,8 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
@@ -330,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
body: projected.body === undefined ? undefined : { ...projected.body },
|
||||
headers: info.headers,
|
||||
},
|
||||
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
|
||||
limits: { context: info.limit.context, output: info.limit.output },
|
||||
providerOptions,
|
||||
},
|
||||
body: {
|
||||
|
||||
@@ -6,7 +6,6 @@ export const Entry = Schema.Struct({
|
||||
path: Schema.String,
|
||||
description: Schema.String,
|
||||
signature: Schema.String,
|
||||
pinned: Schema.optionalKey(Schema.Boolean),
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
@@ -57,39 +56,26 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
if (left.path > right.path) return 1
|
||||
return 0
|
||||
})
|
||||
const ranked = rankListings(listings)
|
||||
const pinned = new Set(
|
||||
namespaceEntries
|
||||
.filter((entry) => entry.pinned)
|
||||
.map((entry) => listings.find((listing) => listing.path === entry.path))
|
||||
.filter((listing) => listing !== undefined),
|
||||
)
|
||||
return {
|
||||
name,
|
||||
listings,
|
||||
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
|
||||
selectedListings: pinned,
|
||||
selectionIndex: 0,
|
||||
selectionOrder: rankListings(listings),
|
||||
selectedListings: new Set<typeof Listing.Type>(),
|
||||
}
|
||||
})
|
||||
|
||||
const active = new Set(namespaces)
|
||||
let remaining =
|
||||
budget -
|
||||
namespaces
|
||||
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
|
||||
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
|
||||
let remaining = budget
|
||||
while (active.size > 0) {
|
||||
for (const namespace of active) {
|
||||
const candidate = namespace.selectionOrder[namespace.selectionIndex]
|
||||
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
|
||||
if (!candidate || candidate.cost > remaining) {
|
||||
active.delete(namespace)
|
||||
continue
|
||||
}
|
||||
namespace.selectedListings.add(candidate.listing)
|
||||
namespace.selectionIndex += 1
|
||||
remaining -= candidate.cost
|
||||
if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
|
||||
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,14 +138,7 @@ export const create = (
|
||||
}
|
||||
|
||||
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
|
||||
const pinned = new Set(
|
||||
Array.from(registrations.values())
|
||||
.filter((registration) => registration.options?.pinned === true)
|
||||
.map(qualifiedName),
|
||||
)
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
|
||||
.catalog()
|
||||
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
|
||||
}
|
||||
|
||||
function runtime(
|
||||
@@ -156,7 +149,11 @@ function runtime(
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(registration)
|
||||
const path = qualifiedName(registration)
|
||||
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const path =
|
||||
registration.options?.namespace === undefined
|
||||
? normalized
|
||||
: `${registration.options.namespace}.${normalized}`
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
@@ -167,12 +164,6 @@ function runtime(
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function qualifiedName(registration: Info) {
|
||||
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
if (registration.options?.namespace === undefined) return normalized
|
||||
return `${registration.options.namespace}.${normalized}`
|
||||
}
|
||||
|
||||
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
|
||||
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigMedia } from "./config/media"
|
||||
import { ConfigAttachments } from "./config/attachments"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
@@ -85,8 +85,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
|
||||
description: "Attachment processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigMedia from "./media"
|
||||
export * as ConfigAttachments from "./attachments"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||
max_width: PositiveInt.pipe(Schema.optional),
|
||||
max_height: PositiveInt.pipe(Schema.optional),
|
||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||
export class Info extends Schema.Class<Info>("Config.Attachments")({
|
||||
image: Image.pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -0,0 +1,181 @@
|
||||
export * as MoveSession from "./move-session"
|
||||
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Project } from "../project"
|
||||
import { Session } from "../session"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import path from "path"
|
||||
|
||||
export const Destination = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "MoveSession.Destination" })
|
||||
export type Destination = typeof Destination.Type
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
sessionID: SessionSchema.ID,
|
||||
destination: Destination,
|
||||
moveChanges: Schema.optional(Schema.Boolean),
|
||||
}).annotate({ identifier: "MoveSession.Input" })
|
||||
export type Input = typeof Input.Type
|
||||
|
||||
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
|
||||
"MoveSession.DestinationProjectMismatchError",
|
||||
{
|
||||
expected: Project.ID,
|
||||
actual: Project.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
|
||||
"MoveSession.DestinationNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
|
||||
"MoveSession.DestinationNotDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
|
||||
"MoveSession.CaptureChangesError",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
|
||||
"MoveSession.ResetSourceChangesError",
|
||||
{
|
||||
directory: AbsolutePath,
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| Session.NotFoundError
|
||||
| DestinationProjectMismatchError
|
||||
| DestinationNotFoundError
|
||||
| DestinationNotDirectoryError
|
||||
| Session.DestinationNotFoundError
|
||||
| Session.DestinationNotDirectoryError
|
||||
| CaptureChangesError
|
||||
| ApplyChangesError
|
||||
| ResetSourceChangesError
|
||||
|
||||
export interface Interface {
|
||||
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const project = yield* Project.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
|
||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||
const current = yield* sessions.get(input.sessionID)
|
||||
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
|
||||
const value = input.destination.directory.trim()
|
||||
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
|
||||
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
|
||||
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
if (current.location.directory === directory) return
|
||||
|
||||
const source = yield* project.resolve(current.location.directory)
|
||||
const destination = yield* project.resolve(directory)
|
||||
if (input.moveChanges && current.projectID !== destination.id) {
|
||||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
||||
}
|
||||
// A move must not race active execution: a mid-drain relocation would let
|
||||
// the source Location dispatch a request assembled under stale instructions
|
||||
// and history. Serialize like removal does — stop the drain, then move.
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
|
||||
const moveChanges = input.moveChanges && source.directory !== destination.directory
|
||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
||||
if (moveChanges && !sourceRepository)
|
||||
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
|
||||
const patch = sourceRepository
|
||||
? yield* git.change
|
||||
.capture({ repository: sourceRepository, path: current.location.directory })
|
||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
||||
: Git.ChangeSet.make("")
|
||||
if (patch) {
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
|
||||
yield* git.change
|
||||
.apply({ repository, path: directory, changes: patch })
|
||||
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
||||
}
|
||||
|
||||
yield* session.move({
|
||||
sessionID: input.sessionID,
|
||||
directory,
|
||||
})
|
||||
|
||||
if (patch) {
|
||||
const repository = yield* git.repo.discover(current.location.directory)
|
||||
if (!repository)
|
||||
return yield* new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: "Source is not a Git repository",
|
||||
})
|
||||
yield* git.change
|
||||
.discard({
|
||||
repository,
|
||||
path: current.location.directory,
|
||||
index: "preserve",
|
||||
untracked: "remove",
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: error.message,
|
||||
cause: error.cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ moveSession })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
FSUtil.node,
|
||||
Git.node,
|
||||
Global.node,
|
||||
Project.node,
|
||||
Session.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
],
|
||||
})
|
||||
-1
@@ -58,6 +58,5 @@ export const migrations = (
|
||||
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||
import("./migration/20260722170000_canonical_tool_results"),
|
||||
import("./migration/20260729022634_session_fork_boundary"),
|
||||
import("./migration/20260730195856_optional_session_title"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260730195856_optional_session_title",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -217,7 +217,7 @@ export default {
|
||||
\`slug\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`path\` text,
|
||||
\`title\` text,
|
||||
\`title\` text NOT NULL,
|
||||
\`version\` text NOT NULL,
|
||||
\`share_url\` text,
|
||||
\`summary_additions\` integer,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
|
||||
export interface Target {
|
||||
readonly canonical: string
|
||||
@@ -109,13 +108,13 @@ const layer = Layer.effect(
|
||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const next = Bom.split(input.content)
|
||||
const next = splitBom(input.content)
|
||||
const current = yield* fs
|
||||
.readFile(input.target.canonical)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
yield* fs.writeWithDirs(
|
||||
input.target.canonical,
|
||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
}),
|
||||
@@ -173,6 +172,20 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function splitBom(text: string) {
|
||||
const stripped = text.replace(/^\uFEFF+/, "")
|
||||
return { bom: stripped.length !== text.length, text: stripped }
|
||||
}
|
||||
|
||||
function joinBom(text: string, bom: boolean) {
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function hasUtf8Bom(content: Uint8Array) {
|
||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
}
|
||||
|
||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((byte, index) => byte === right[index])
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
export * as Formatter from "./formatter"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { make, type Info } from "./formatter/builtins"
|
||||
|
||||
export const Status = Schema.Struct({
|
||||
name: Schema.String,
|
||||
extensions: Schema.Array(Schema.String),
|
||||
enabled: Schema.Boolean,
|
||||
}).annotate({ identifier: "FormatterStatus" })
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Formatter") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
const load = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
||||
if (!configured) {
|
||||
yield* Effect.logInfo("all formatters are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
if (entry.disabled) {
|
||||
if (index !== -1) formatters.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
const formatter: Info = {
|
||||
name,
|
||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||
environment: { ...builtIn?.environment, ...entry.environment },
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
if (index === -1) formatters.push(formatter)
|
||||
else formatters[index] = formatter
|
||||
}
|
||||
}).pipe(Effect.withSpan("Formatter.load")),
|
||||
)
|
||||
|
||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||
const cached = commands.get(formatter.name)
|
||||
if (cached !== undefined) return cached
|
||||
const result = yield* formatter.enabled
|
||||
if (result !== false) commands.set(formatter.name, result)
|
||||
return result
|
||||
})
|
||||
|
||||
const init = Effect.fn("Formatter.init")(function* () {
|
||||
yield* load
|
||||
})
|
||||
|
||||
const status = Effect.fn("Formatter.status")(function* () {
|
||||
yield* load
|
||||
return yield* Effect.forEach(formatters, (formatter) =>
|
||||
command(formatter).pipe(
|
||||
Effect.map((enabled) => ({
|
||||
name: formatter.name,
|
||||
extensions: [...formatter.extensions],
|
||||
enabled: enabled !== false,
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) =>
|
||||
formatter.extensions.includes(path.extname(filepath)),
|
||||
)
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
if (enabled === false) continue
|
||||
const cmd = enabled.map((argument) => argument.replace("$FILE", filepath))
|
||||
yield* Effect.logInfo("formatting file", { file: filepath, command: cmd })
|
||||
const result = yield* processes
|
||||
.run(
|
||||
ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||
cwd: location.directory,
|
||||
env: formatter.environment,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("failed to format file", {
|
||||
file: filepath,
|
||||
command: cmd,
|
||||
error: error.message,
|
||||
}).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!result) continue
|
||||
if (result.exitCode === 0) return true
|
||||
yield* Effect.logError("formatter exited unsuccessfully", {
|
||||
file: filepath,
|
||||
command: cmd,
|
||||
exitCode: result.exitCode,
|
||||
})
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ init, status, file })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
|
||||
})
|
||||
@@ -1,314 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export interface Info {
|
||||
readonly name: string
|
||||
readonly environment?: Record<string, string>
|
||||
readonly extensions: readonly string[]
|
||||
readonly enabled: Effect.Effect<string[] | false>
|
||||
}
|
||||
|
||||
export function make(input: {
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
|
||||
const commandOutput = (command: string[]) =>
|
||||
input.processes
|
||||
.run(
|
||||
ChildProcess.make(command[0], command.slice(1), {
|
||||
cwd: input.directory,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.option)
|
||||
|
||||
const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
extensions: [".go"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("gofmt")
|
||||
return match ? [match, "-w", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const mix: Info = {
|
||||
name: "mix",
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("mix")
|
||||
return match ? [match, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const prettier: Info = {
|
||||
name: "prettier",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".vue",
|
||||
".svelte",
|
||||
".json",
|
||||
".jsonc",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".xml",
|
||||
".md",
|
||||
".mdx",
|
||||
".graphql",
|
||||
".gql",
|
||||
],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("package.json")) {
|
||||
if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue
|
||||
const bin = yield* input.npm.which("prettier")
|
||||
if (bin) return [bin, "--write", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const oxfmt: Info = {
|
||||
name: "oxfmt",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("package.json")) {
|
||||
if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue
|
||||
const bin = yield* input.npm.which("oxfmt")
|
||||
if (bin) return [bin, "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const biome: Info = {
|
||||
name: "biome",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".vue",
|
||||
".svelte",
|
||||
".json",
|
||||
".jsonc",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".xml",
|
||||
".md",
|
||||
".mdx",
|
||||
".graphql",
|
||||
".gql",
|
||||
],
|
||||
enabled: Effect.gen(function* () {
|
||||
const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" })
|
||||
if (!found.some((items) => items.length > 0)) return disabled
|
||||
const bin = yield* input.npm.which("@biomejs/biome")
|
||||
return bin ? [bin, "format", "--write", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const zig: Info = {
|
||||
name: "zig",
|
||||
extensions: [".zig", ".zon"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("zig")
|
||||
return match ? [match, "fmt", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const clang: Info = {
|
||||
name: "clang-format",
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".clang-format")).length) return disabled
|
||||
const match = which("clang-format")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
extensions: [".kt", ".kts"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("ktlint")
|
||||
return match ? [match, "-F", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const ruff: Info = {
|
||||
name: "ruff",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!which("ruff")) return disabled
|
||||
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
|
||||
const found = yield* findUp(config)
|
||||
if (!found.length) continue
|
||||
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
|
||||
return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
|
||||
const found = yield* findUp(dependency)
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const air: Info = {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("air")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "--help"])
|
||||
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
|
||||
const first = output.value.stdout.toString("utf8").split("\n")[0]
|
||||
return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const uv: Info = {
|
||||
name: "uv",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("uv")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "format", "--help"])
|
||||
return output._tag === "Some" && output.value.exitCode === 0
|
||||
? [bin, "format", "--", "$FILE"]
|
||||
: disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"])
|
||||
|
||||
const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
extensions: [".ml", ".mli"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".ocamlformat")).length) return disabled
|
||||
const match = which("ocamlformat")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
|
||||
|
||||
const pint: Info = {
|
||||
name: "pint",
|
||||
extensions: [".php"],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("composer.json")) {
|
||||
const json = yield* input.fs.readJson(file)
|
||||
if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) {
|
||||
return ["./vendor/bin/pint", "$FILE"]
|
||||
}
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
|
||||
|
||||
return [
|
||||
gofmt,
|
||||
mix,
|
||||
oxfmt,
|
||||
prettier,
|
||||
biome,
|
||||
zig,
|
||||
clang,
|
||||
ktlint,
|
||||
ruff,
|
||||
air,
|
||||
uv,
|
||||
rubocop,
|
||||
standardrb,
|
||||
htmlbeautifier,
|
||||
dart,
|
||||
ocamlformat,
|
||||
terraform,
|
||||
latexindent,
|
||||
gleam,
|
||||
shfmt,
|
||||
nixfmt,
|
||||
rustfmt,
|
||||
pint,
|
||||
ormolu,
|
||||
cljfmt,
|
||||
dfmt,
|
||||
] satisfies Info[]
|
||||
}
|
||||
|
||||
function executable(name: string, extensions: readonly string[], args: string[]): Info {
|
||||
return {
|
||||
name,
|
||||
extensions,
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which(name)
|
||||
return match ? [match, ...args] : false
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function hasDependency(input: unknown, dependency: string) {
|
||||
return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency)
|
||||
}
|
||||
|
||||
function hasRecordKey(input: unknown, field: string, key: string) {
|
||||
if (!isRecord(input)) return false
|
||||
return isRecord(input[field]) && key in input[field]
|
||||
}
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return Boolean(input && typeof input === "object" && !Array.isArray(input))
|
||||
}
|
||||
@@ -201,7 +201,7 @@ const layer = Layer.effect(
|
||||
locks.withLock(repository.gitDirectory)(effect)
|
||||
|
||||
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export const imageGenerationArgsSchema = z
|
||||
@@ -23,3 +24,91 @@ export const imageGenerationArgsSchema = z
|
||||
export const imageGenerationOutputSchema = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
type ImageGenerationArgs = {
|
||||
/**
|
||||
* Background type for the generated image. Default is 'auto'.
|
||||
*/
|
||||
background?: "auto" | "opaque" | "transparent"
|
||||
|
||||
/**
|
||||
* Input fidelity for the generated image. Default is 'low'.
|
||||
*/
|
||||
inputFidelity?: "low" | "high"
|
||||
|
||||
/**
|
||||
* Optional mask for inpainting.
|
||||
* Contains image_url (string, optional) and file_id (string, optional).
|
||||
*/
|
||||
inputImageMask?: {
|
||||
/**
|
||||
* File ID for the mask image.
|
||||
*/
|
||||
fileId?: string
|
||||
|
||||
/**
|
||||
* Base64-encoded mask image.
|
||||
*/
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The image generation model to use. Default: gpt-image-1.
|
||||
*/
|
||||
model?: string
|
||||
|
||||
/**
|
||||
* Moderation level for the generated image. Default: auto.
|
||||
*/
|
||||
moderation?: "auto"
|
||||
|
||||
/**
|
||||
* Compression level for the output image. Default: 100.
|
||||
*/
|
||||
outputCompression?: number
|
||||
|
||||
/**
|
||||
* The output format of the generated image. One of png, webp, or jpeg.
|
||||
* Default: png
|
||||
*/
|
||||
outputFormat?: "png" | "jpeg" | "webp"
|
||||
|
||||
/**
|
||||
* Number of partial images to generate in streaming mode, from 0 (default value) to 3.
|
||||
*/
|
||||
partialImages?: number
|
||||
|
||||
/**
|
||||
* The quality of the generated image.
|
||||
* One of low, medium, high, or auto. Default: auto.
|
||||
*/
|
||||
quality?: "auto" | "low" | "medium" | "high"
|
||||
|
||||
/**
|
||||
* The size of the generated image.
|
||||
* One of 1024x1024, 1024x1536, 1536x1024, or auto.
|
||||
* Default: auto.
|
||||
*/
|
||||
size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024"
|
||||
}
|
||||
|
||||
const imageGenerationToolFactory = createProviderToolFactoryWithOutputSchema<
|
||||
{},
|
||||
{
|
||||
/**
|
||||
* The generated image encoded in base64.
|
||||
*/
|
||||
result: string
|
||||
},
|
||||
ImageGenerationArgs
|
||||
>({
|
||||
id: "openai.image_generation",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: imageGenerationOutputSchema,
|
||||
})
|
||||
|
||||
export const imageGeneration = (
|
||||
args: ImageGenerationArgs = {}, // default
|
||||
) => {
|
||||
return imageGenerationToolFactory(args)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export const localShellInputSchema = z.object({
|
||||
@@ -14,3 +15,50 @@ export const localShellInputSchema = z.object({
|
||||
export const localShellOutputSchema = z.object({
|
||||
output: z.string(),
|
||||
})
|
||||
|
||||
export const localShell = createProviderToolFactoryWithOutputSchema<
|
||||
{
|
||||
/**
|
||||
* Execute a shell command on the server.
|
||||
*/
|
||||
action: {
|
||||
type: "exec"
|
||||
|
||||
/**
|
||||
* The command to run.
|
||||
*/
|
||||
command: string[]
|
||||
|
||||
/**
|
||||
* Optional timeout in milliseconds for the command.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
|
||||
/**
|
||||
* Optional user to run the command as.
|
||||
*/
|
||||
user?: string
|
||||
|
||||
/**
|
||||
* Optional working directory to run the command in.
|
||||
*/
|
||||
workingDirectory?: string
|
||||
|
||||
/**
|
||||
* Environment variables to set for the command.
|
||||
*/
|
||||
env?: Record<string, string>
|
||||
}
|
||||
},
|
||||
{
|
||||
/**
|
||||
* The output of local shell tool call.
|
||||
*/
|
||||
output: string
|
||||
},
|
||||
{}
|
||||
>({
|
||||
id: "openai.local_shell",
|
||||
inputSchema: localShellInputSchema,
|
||||
outputSchema: localShellOutputSchema,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
// Args validation schema
|
||||
@@ -38,3 +39,65 @@ export const webSearchPreviewArgsSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const webSearchPreview = createProviderToolFactory<
|
||||
{
|
||||
// Web search doesn't take input parameters - it's controlled by the prompt
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Search context size to use for the web search.
|
||||
* - high: Most comprehensive context, highest cost, slower response
|
||||
* - medium: Balanced context, cost, and latency (default)
|
||||
* - low: Least context, lowest cost, fastest response
|
||||
*/
|
||||
searchContextSize?: "low" | "medium" | "high"
|
||||
|
||||
/**
|
||||
* User location information to provide geographically relevant search results.
|
||||
*/
|
||||
userLocation?: {
|
||||
/**
|
||||
* Type of location (always 'approximate')
|
||||
*/
|
||||
type: "approximate"
|
||||
/**
|
||||
* Two-letter ISO country code (e.g., 'US', 'GB')
|
||||
*/
|
||||
country?: string
|
||||
/**
|
||||
* City name (free text, e.g., 'Minneapolis')
|
||||
*/
|
||||
city?: string
|
||||
/**
|
||||
* Region name (free text, e.g., 'Minnesota')
|
||||
*/
|
||||
region?: string
|
||||
/**
|
||||
* IANA timezone (e.g., 'America/Chicago')
|
||||
*/
|
||||
timezone?: string
|
||||
}
|
||||
}
|
||||
>({
|
||||
id: "openai.web_search_preview",
|
||||
inputSchema: z.object({
|
||||
action: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string().nullish(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("open_page"),
|
||||
url: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("find"),
|
||||
url: z.string(),
|
||||
pattern: z.string(),
|
||||
}),
|
||||
])
|
||||
.nullish(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export const webSearchArgsSchema = z.object({
|
||||
@@ -19,3 +20,83 @@ export const webSearchArgsSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const webSearchToolFactory = createProviderToolFactory<
|
||||
{
|
||||
// Web search doesn't take input parameters - it's controlled by the prompt
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Filters for the search.
|
||||
*/
|
||||
filters?: {
|
||||
/**
|
||||
* Allowed domains for the search.
|
||||
* If not provided, all domains are allowed.
|
||||
* Subdomains of the provided domains are allowed as well.
|
||||
*/
|
||||
allowedDomains?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Search context size to use for the web search.
|
||||
* - high: Most comprehensive context, highest cost, slower response
|
||||
* - medium: Balanced context, cost, and latency (default)
|
||||
* - low: Least context, lowest cost, fastest response
|
||||
*/
|
||||
searchContextSize?: "low" | "medium" | "high"
|
||||
|
||||
/**
|
||||
* User location information to provide geographically relevant search results.
|
||||
*/
|
||||
userLocation?: {
|
||||
/**
|
||||
* Type of location (always 'approximate')
|
||||
*/
|
||||
type: "approximate"
|
||||
/**
|
||||
* Two-letter ISO country code (e.g., 'US', 'GB')
|
||||
*/
|
||||
country?: string
|
||||
/**
|
||||
* City name (free text, e.g., 'Minneapolis')
|
||||
*/
|
||||
city?: string
|
||||
/**
|
||||
* Region name (free text, e.g., 'Minnesota')
|
||||
*/
|
||||
region?: string
|
||||
/**
|
||||
* IANA timezone (e.g., 'America/Chicago')
|
||||
*/
|
||||
timezone?: string
|
||||
}
|
||||
}
|
||||
>({
|
||||
id: "openai.web_search",
|
||||
inputSchema: z.object({
|
||||
action: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string().nullish(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("open_page"),
|
||||
url: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("find"),
|
||||
url: z.string(),
|
||||
pattern: z.string(),
|
||||
}),
|
||||
])
|
||||
.nullish(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const webSearch = (
|
||||
args: Parameters<typeof webSearchToolFactory>[0] = {}, // default
|
||||
) => {
|
||||
return webSearchToolFactory(args)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const layer = Layer.effect(
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
||||
),
|
||||
)
|
||||
const normalize = yield* loadAdapter
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { Formatter } from "./formatter"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
@@ -74,7 +73,6 @@ const locationServiceNodes = [
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
@@ -148,3 +146,6 @@ export function buildLocationServiceMap(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// This is temporary for backwards compatibility
|
||||
export const locationServiceMapLayer = buildLocationServiceMap()
|
||||
|
||||
@@ -25,7 +25,7 @@ const layer = (ref: Ref) =>
|
||||
return Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
|
||||
project: { id: resolved.id, directory: resolved.directory },
|
||||
vcs: resolved.vcs,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -12,12 +12,12 @@ import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { AISDKNative } from "./aisdk-native"
|
||||
import { Catalog } from "./catalog"
|
||||
import { Credential } from "./credential"
|
||||
import { Integration } from "./integration"
|
||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OpenAICodex } from "./plugin/provider/openai-codex"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: Info) => {
|
||||
@@ -96,7 +96,9 @@ const providerHeaders = (model: Info) => {
|
||||
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||
}
|
||||
|
||||
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
const providerOptions = (
|
||||
model: Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||
if (Object.keys(settings).length === 0) return undefined
|
||||
@@ -150,7 +152,12 @@ export const fromCatalogModel = (
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
|
||||
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
||||
return Effect.succeed(codexModel(resolved, credential, key))
|
||||
}
|
||||
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
@@ -175,41 +182,31 @@ export const fromCatalogModel = (
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
const configured = {
|
||||
...resolved.settings,
|
||||
...credential?.metadata,
|
||||
...(packageName === "@ai-sdk/amazon-bedrock/mantle"
|
||||
? nativeCredentialSettings("@opencode-ai/ai/providers/amazon-bedrock/mantle", credential)
|
||||
: {}),
|
||||
}
|
||||
const mapping = Provider.isAISDK(resolved.package)
|
||||
? AISDKNative.map(packageName, configured, resolved.modelID ?? resolved.id)
|
||||
: undefined
|
||||
const native = mapping?.package ?? resolved.package
|
||||
if (Provider.isAISDK(resolved.package) && !mapping) {
|
||||
if (Provider.isAISDK(resolved.package)) {
|
||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
...nativeCredentialSettings(resolved.package ?? "", credential),
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...credential?.metadata,
|
||||
})
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!native) return Effect.fail(unsupported(resolved))
|
||||
if (!resolved.package) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = native
|
||||
const specifier = resolved.package
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const mapped = mapping?.settings ?? configured
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...(credential ? withoutNativeAuthSettings(configured) : configured),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
@@ -226,6 +223,10 @@ export const fromCatalogModel = (
|
||||
})
|
||||
}
|
||||
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
@@ -247,6 +248,22 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const codexModel = (
|
||||
model: Info,
|
||||
credential: Credential.Value | undefined,
|
||||
key: ReturnType<typeof Auth.value> | undefined,
|
||||
) => {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return withDefaults(model, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
@@ -271,7 +288,10 @@ export const layer = Layer.effect(
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (
|
||||
selected: Info,
|
||||
variant?: VariantID,
|
||||
) {
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Permission } from "../permission"
|
||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||
const BUILD_SYSTEM =
|
||||
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as PluginHooks from "./hooks"
|
||||
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -11,7 +10,6 @@ import { State } from "../state"
|
||||
export interface Domains {
|
||||
readonly aisdk: AISDKHooks
|
||||
readonly session: SessionHooks
|
||||
readonly shell: ShellHooks
|
||||
readonly tool: ToolHooks
|
||||
}
|
||||
|
||||
|
||||
@@ -296,9 +296,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
})
|
||||
}),
|
||||
},
|
||||
shell: {
|
||||
hook: (name, callback) => hooks.register("shell", name, callback),
|
||||
},
|
||||
tool: {
|
||||
transform: (callback) =>
|
||||
tools
|
||||
@@ -341,7 +338,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
create: (input) =>
|
||||
runtime.session.create({
|
||||
id: input?.id,
|
||||
title: input?.title,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
location:
|
||||
@@ -351,10 +347,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
prompt: runtime.session.prompt,
|
||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||
command: runtime.session.command,
|
||||
rename: runtime.session.rename,
|
||||
synthetic: runtime.session.synthetic,
|
||||
interrupt: (input) => runtime.session.interrupt(input.sessionID),
|
||||
wait: (input) => runtime.session.wait(input.sessionID),
|
||||
},
|
||||
} satisfies Plugin.Context
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user