Compare commits

...
45 changed files with 771 additions and 290 deletions
+17 -17
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.14.48", "version": "1.14.49",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -253,7 +253,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -307,7 +307,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -337,7 +337,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -353,7 +353,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -366,7 +366,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
@@ -384,7 +384,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.14.48", "version": "1.14.49",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -520,7 +520,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -558,7 +558,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -573,7 +573,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -608,7 +608,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -657,7 +657,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-cRhvzZoW6gBbE0sQm1+e+6/WgajuA6MSIL5iroFsfqs=", "x86_64-linux": "sha256-QIj9PhOXR/GngV/dPjCF7n5rKro2fcTNGzJ47a41Z2Q=",
"aarch64-linux": "sha256-0knZfxBULqkt5u6sXFx+a/vqw2rc6IC1+LeAd4TNFhM=", "aarch64-linux": "sha256-fQl7BjjTYtRKT3HRVhubaIVww/puUFSTzVV5bTy8II8=",
"aarch64-darwin": "sha256-jL4tO+EHSmUF+gQGEaLzAbTxxjkL8OyhTk13vsbomgM=", "aarch64-darwin": "sha256-81IAmdjiYZz8IgMJt0+VxzdOS80gTHc5SendwEW/vD4=",
"x86_64-darwin": "sha256-bsa7IpS3GaxagcigTa0yqZTkf4e/nbcTQ9aZeb+5eHQ=" "x86_64-darwin": "sha256-5OMX4VVBMfEmkYvzd09oksAt5hKkxDs84miO804LBI8="
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.48", "version": "1.14.49",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.14.48", "version": "1.14.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -216,7 +216,7 @@ export async function handler(
// ie. 400 error is usually provider error like malformed request // ie. 400 error is usually provider error like malformed request
res.status !== 400 && res.status !== 400 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found. // ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 && !(modelInfo.id.startsWith("gpt-") && res.status === 404) &&
// ie. cannot change codex model providers mid-session // ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" && modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider && modelInfo.fallbackProvider &&
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.14.48", "version": "1.14.49",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.14.48", "version": "1.14.49",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.14.48", "version": "1.14.49",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48", "version": "1.14.49",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -0,0 +1,94 @@
export * as LayerMapExample from "./layer-map.example"
import { Context, Effect, Layer, LayerMap } from "effect"
import { Npm } from "../npm"
/**
* Tutorial: split global services from context-specific services.
*
* Use this pattern when part of the app should be constructed once at the app edge,
* while another part should be cached per request/project/workspace key.
*
* In this example:
* - Npm.Service is the global service. It is not keyed by request context and should
* be provided once by the application runtime.
* - ConfigService is context-specific. It is built from a RequestContext key and is
* cached by LayerMap for that key.
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
* request can provide ConfigServiceMap.get(context) to select the right instance.
*
* Lifetime model:
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
* ConfigService for exactly that context key.
* - The cached ConfigService entry stays alive while something is using it. Once idle,
* it remains cached for idleTimeToLive, then its scope is finalized.
* - invalidate(context) removes the cache entry for future lookups. Active users keep
* running on the old instance; the next lookup can create a fresh instance.
*
* Key model:
* - Keys can be strings, structs, classes, arrays, etc.
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
* lookup, so mutating an object after it has been used as a key is a bug.
*/
export type RequestContext = {
readonly directory: string
readonly workspace: string
}
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
"@opencode/example/RequestContextRef",
) {}
export interface ConfigServiceShape {
readonly directory: string
readonly workspace: string
readonly nextUse: () => Effect.Effect<number>
readonly which: Npm.Interface["which"]
}
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
"@opencode/example/ConfigService",
) {}
const configServiceLayer = Layer.effect(
ConfigService,
Effect.gen(function* () {
const context = yield* RequestContextRef
const npm = yield* Npm.Service
let useCount = 0
return ConfigService.of({
directory: context.directory,
workspace: context.workspace,
nextUse: () => Effect.succeed(++useCount),
which: npm.which,
})
}),
)
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
lookup: (context: RequestContext) =>
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
idleTimeToLive: "5 minutes",
}) {}
export const appLayer = ConfigServiceMap.layer
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
const config = yield* ConfigService
return {
directory: config.directory,
workspace: config.workspace,
useCount: yield* config.nextUse(),
}
})
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
})
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.14.48", "version": "1.14.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.14.48", "version": "1.14.49",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.14.48" version = "1.14.49"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.49/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.14.48", "version": "1.14.49",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48", "version": "1.14.49",
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48", "version": "1.14.49",
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.14.48", "version": "1.14.49",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -0,0 +1,235 @@
# Error Boundaries Plan
Plan for removing `NamedError` as connective tissue while keeping public
wire contracts stable.
## Desired Shape
```text
Domain/service error
Schema.TaggedErrorClass
- catchable with catchTag / catchTags
- appears in service method error type
- no HTTP status
- no toObject()
HTTP public error
Schema.ErrorClass / TaggedErrorClass with httpApiStatus
- endpoint-declared public contract
- owns legacy { name, data } only when that is the SDK wire shape
CLI/user rendering
FormatError and small format helpers
- converts domain errors to text
- preserves useful structured fields
Session/model-visible error
first-class session/message error schema or helper
- owns { name, data } event/message shape
- not a service error class
```
The important rule: a service error should not also be the HTTP body, CLI
formatter, and session event body. Each seam adapts the error into the
shape it owns.
## Concrete Example: Provider Model Not Found
Before:
```ts
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
```
Problems:
- Throwing it inside `Effect.fn` made it behave like a defect unless a
compatibility bridge caught it.
- HTTP middleware knew that this one domain error should be a `400`.
- Callers read `.data.*`, which couples them to the legacy `{ name, data }`
wire shape.
After:
```ts
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {}
export interface Interface {
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
}
```
Boundary adapters:
```text
CLI
└─ FormatError sees _tag ProviderModelNotFoundError -> nice text
Session prompt
└─ catch ModelNotFoundError -> publish Session.Event.Error as message/session wire shape
HTTP route
└─ catch ModelNotFoundError -> declared BadRequest public API error when the endpoint needs it
HTTP middleware
└─ no Provider.ModelNotFoundError knowledge
```
## Refining Known Promise Failures
Use `EffectPromise.refineRejection(...)` when a Promise boundary can reject
with many unknown values, but only one or two rejection classes are expected
domain failures. Unknown rejections stay defects; the helper maps only known
rejection shapes to typed errors.
```ts
const language =
yield *
EffectPromise.refineRejection(
async () => loadFromProvider(),
(cause) => (cause instanceof NoSuchModelError ? new ModelNotFoundError({ providerID, modelID, cause }) : undefined),
)
```
Use this when the Promise can genuinely reject and most rejection values are
still defects for the current module. Use `Effect.tryPromise({ try, catch })`
when every rejection should become the same expected error type. Use
`Effect.promise(...)` only when rejection means a defect and you do not need
to refine known rejection classes.
## Helper Modules We Probably Want
Add helpers only when repeated call sites prove the seam is real.
### HTTP API Errors
Likely location: `src/server/routes/instance/httpapi/errors.ts`.
Purpose:
- construct public HTTP error bodies
- preserve legacy `{ name, data }` where needed
- attach `httpApiStatus`
Good helpers:
```ts
notFound(message)
badRequest(message)
unknown()
```
Avoid:
```ts
mapAnyDomainError(error)
```
That recreates the giant middleware mapper problem.
### Session / Message Error Wire Helpers
Likely location: near `src/session/message-error.ts` or a new narrow
module such as `src/session/event-error.ts`.
Purpose:
- construct the `{ name, data }` shape used by `Session.Event.Error` and
assistant message errors
- replace `new NamedError.Unknown(...).toObject()` call sites
- keep model-visible error bodies separate from service/domain errors
Good helpers:
```ts
unknown(message)
agentNotFound(agent, available)
commandNotFound(command, available)
modelNotFound(error: Provider.ModelNotFoundError)
```
### CLI Formatters
Likely location: `src/cli/error.ts` until repetition demands domain-local
format helpers.
Purpose:
- produce human-readable terminal messages from typed errors
- support old `{ name, data }` shapes only while compatibility is needed
## Migration Queue
### Remove Domain Knowledge From HTTP Middleware
- [x] Storage not found no longer maps through defect fallback.
- [x] Worktree expected errors moved to typed errors.
- [x] Provider auth expected errors moved to typed errors.
- [x] Provider model not found no longer needs an HTTP middleware status
special case.
- [ ] Convert `Session.BusyError` and map it at route boundaries.
- [ ] Delete the broad `NamedError` middleware branch once no route relies
on defect-wrapped legacy domain errors.
- [ ] Keep one final unknown-defect fallback that logs `Cause.pretty(cause)`
and returns a safe `500` body.
### Remaining `NamedError.create(...)` Service Errors
These should become `Schema.TaggedErrorClass` when touched:
- [ ] `src/provider/provider.ts``ProviderInitError`.
- [ ] `src/storage/db.ts` — database `NotFoundError`.
- [ ] `src/mcp/index.ts``MCPFailed`.
- [ ] `src/skill/index.ts``SkillInvalidError`,
`SkillNameMismatchError`.
- [ ] `src/lsp/client.ts``LSPInitializeError`.
- [ ] `src/ide/index.ts` — install errors.
- [ ] `src/config/error.ts`, `src/config/config.ts`,
`src/config/markdown.ts` — config errors. These already render well
in the CLI, so migrate carefully and preserve diagnostics.
### Session / Message Wire Errors
These are not ordinary service errors. They mostly build `{ name, data }`
objects for model-visible/session-visible output.
- [ ] Add a first-class session/message error wire helper.
- [ ] Replace `new NamedError.Unknown(...).toObject()` in
`src/session/prompt.ts`.
- [ ] Replace `new NamedError.Unknown(...).toObject()` in config/skill/plugin
session event publishing.
- [ ] Move `src/session/message-error.ts` and `src/session/message-v2.ts`
away from `NamedError.create(...)` once the wire helper exists.
- [ ] Update retry/message tests to assert the wire schema/helper output,
not `NamedError` instances.
### CLI Rendering
- [x] Tagged config errors render with useful diagnostics.
- [x] Provider model not found renders from both old `{ name, data }` and
new `_tag` shapes.
- [ ] Add typed render cases as more `NamedError.create(...)` domains move
to `Schema.TaggedErrorClass`.
- [ ] Eventually remove old-shape compatibility branches when no callers can
produce them.
## PR Checklist
For each migrated error:
- [ ] Domain error is `Schema.TaggedErrorClass`.
- [ ] Service method exposes the typed error in its error channel.
- [ ] No service error has `toObject()` just for compatibility.
- [ ] CLI, HTTP, and session/message adapters each own their output shape.
- [ ] HTTP middleware gets smaller or stays unchanged.
- [ ] Focused tests cover the domain error and any public rendering/wire
shape touched by the PR.
+3
View File
@@ -4,6 +4,9 @@ This note expands the `ERR`, `RENDER`, and `HTTP` tracks from
[`todo.md`](./todo.md). It is the current reference for expected failures, [`todo.md`](./todo.md). It is the current reference for expected failures,
typed service errors, and HTTP error boundaries. typed service errors, and HTTP error boundaries.
For the migration architecture and queue, see
[`error-boundaries-plan.md`](./error-boundaries-plan.md).
## Goal ## Goal
- Expected service failures live on the Effect error channel. - Expected service failures live on the Effect error channel.
+3 -2
View File
@@ -2,8 +2,9 @@
Short roadmap for Effect cleanup in `packages/opencode`. Short roadmap for Effect cleanup in `packages/opencode`.
Current patterns and examples live in [`guide.md`](./guide.md). Test Current patterns and examples live in [`guide.md`](./guide.md). Error
migration rules live in boundary migration details live in
[`error-boundaries-plan.md`](./error-boundaries-plan.md). Test migration rules live in
[`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md). [`test/EFFECT_TEST_MIGRATION.md`](../../test/EFFECT_TEST_MIGRATION.md).
Older deep-dive notes in this directory may still be useful, but treat Older deep-dive notes in this directory may still be useful, but treat
this roadmap and the guide as the current entry points. this roadmap and the guide as the current entry points.
+8 -5
View File
@@ -62,11 +62,14 @@ export interface Interface {
readonly generate: (input: { readonly generate: (input: {
description: string description: string
model?: { providerID: ProviderID; modelID: ModelID } model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{ }) => Effect.Effect<
identifier: string {
whenToUse: string identifier: string
systemPrompt: string whenToUse: string
}> systemPrompt: string
},
Provider.ModelNotFoundError
>
} }
type State = Omit<Interface, "generate"> type State = Omit<Interface, "generate">
@@ -7,7 +7,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import * as ConfigPaths from "@/config/paths" import { ConfigPaths } from "@/config/paths"
const log = Log.create({ service: "tui.migrate" }) const log = Log.create({ service: "tui.migrate" })
@@ -5,7 +5,7 @@ import { createBindingLookup } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect" import { Cause, Context, Effect, Fiber, Layer, Schema } from "effect"
import { ConfigParse } from "@/config/parse" import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths" import { ConfigPaths } from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate" import { migrateTuiConfig } from "./tui-migrate"
import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema" import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
@@ -99,6 +99,7 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service const afs = yield* AppFileSystem.Service
let appliedOrder = 0 let appliedOrder = 0
const paths = yield* ConfigPaths.Service
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> => const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -191,10 +192,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
// Every config dir we may read from: global config dir, any `.opencode` // Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR. // folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directories = yield* ConfigPaths.directories(ctx.directory) const directories = yield* paths.directories(ctx.directory)
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory })) yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory) const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* paths.projectFiles("tui", ctx.directory)
const acc: Acc = { const acc: Acc = {
result: {}, result: {},
@@ -295,7 +296,11 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("TuiConfig.layer")), }).pipe(Effect.withSpan("TuiConfig.layer")),
) )
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(
Layer.provide(Npm.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(ConfigPaths.defaultLayer),
)
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+7 -8
View File
@@ -1,5 +1,6 @@
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import { errorFormat } from "@/util/error" import { errorFormat } from "@/util/error"
import { isRecord } from "@/util/record"
interface ErrorLike { interface ErrorLike {
name?: string name?: string
@@ -10,10 +11,6 @@ interface ErrorLike {
type ConfigIssue = { message: string; path: string[] } type ConfigIssue = { message: string; path: string[] }
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === "object" && input !== null
}
function isTaggedError(error: unknown, tag: string): boolean { function isTaggedError(error: unknown, tag: string): boolean {
return isRecord(error) && error._tag === tag return isRecord(error) && error._tag === tag
} }
@@ -61,11 +58,13 @@ export function FormatError(input: unknown) {
} }
// ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] }
if (NamedError.hasName(input, "ProviderModelNotFoundError")) { const providerModelNotFound = configData(input, "ProviderModelNotFoundError")
const data = (input as ErrorLike).data if (providerModelNotFound) {
const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : [] const suggestions = Array.isArray(providerModelNotFound.suggestions)
? providerModelNotFound.suggestions.filter((x) => typeof x === "string")
: []
return [ return [
`Model not found: ${data?.providerID}/${data?.modelID}`, `Model not found: ${providerModelNotFound.providerID}/${providerModelNotFound.modelID}`,
...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`opencode models\` to list available models`, `Try: \`opencode models\` to list available models`,
`Or check your config (opencode.json) provider/model names`, `Or check your config (opencode.json) provider/model names`,
+4 -2
View File
@@ -370,6 +370,7 @@ export const layer = Layer.effect(
const accountSvc = yield* Account.Service const accountSvc = yield* Account.Service
const env = yield* Env.Service const env = yield* Env.Service
const npmSvc = yield* Npm.Service const npmSvc = yield* Npm.Service
const paths = yield* ConfigPaths.Service
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
@@ -562,7 +563,7 @@ export const layer = Layer.effect(
} }
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { for (const file of yield* paths.projectFiles("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(file, yield* loadFile(file), "local") yield* merge(file, yield* loadFile(file), "local")
} }
} }
@@ -571,7 +572,7 @@ export const layer = Layer.effect(
result.mode = result.mode || {} result.mode = result.mode || {}
result.plugin = result.plugin || [] result.plugin = result.plugin || []
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree) const directories = yield* paths.directories(ctx.directory, ctx.worktree)
if (Flag.OPENCODE_CONFIG_DIR) { if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
@@ -829,6 +830,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer), Layer.provide(Account.defaultLayer),
Layer.provide(Npm.defaultLayer), Layer.provide(Npm.defaultLayer),
Layer.provide(ConfigPaths.defaultLayer),
) )
export * as Config from "./config" export * as Config from "./config"
+65 -34
View File
@@ -4,42 +4,73 @@ import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { unique } from "remeda" import { unique } from "remeda"
import * as Effect from "effect/Effect" import { Context, Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ConfigParse } from "./parse"
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
name: string,
directory: string,
worktree?: string,
) {
const afs = yield* AppFileSystem.Service
return (yield* afs.up({
targets: [`${name}.jsonc`, `${name}.json`],
start: directory,
stop: worktree,
})).toReversed()
})
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
const afs = yield* AppFileSystem.Service
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? yield* afs.up({
targets: [".opencode"],
start: directory,
stop: worktree,
})
: []),
...(yield* afs.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
})),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
])
})
export function fileInDirectory(dir: string, name: string) { export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
} }
export interface Interface {
readonly projectFiles: (
name: string,
directory: string,
worktree?: string,
) => Effect.Effect<string[], AppFileSystem.Error>
readonly directories: (directory: string, worktree?: string) => Effect.Effect<string[], AppFileSystem.Error>
readonly readFile: (filepath: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly parseText: (text: string, filepath: string) => Effect.Effect<unknown>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigPaths") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* AppFileSystem.Service
const projectFiles = Effect.fn("ConfigPaths.projectFiles")(function* (
name: string,
directory: string,
worktree?: string,
) {
return (yield* afs.up({
targets: [`${name}.jsonc`, `${name}.json`],
start: directory,
stop: worktree,
})).toReversed()
})
const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? yield* afs.up({
targets: [".opencode"],
start: directory,
stop: worktree,
})
: []),
...(yield* afs.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
})),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
])
})
const readFile = Effect.fn("ConfigPaths.readFile")(function* (filepath: string) {
return yield* afs.readFileStringSafe(filepath)
})
const parseText = Effect.fn("ConfigPaths.parseText")(function* (text: string, filepath: string) {
return ConfigParse.jsonc(text, filepath)
})
return Service.of({ projectFiles, directories, readFile, parseText })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+17
View File
@@ -0,0 +1,17 @@
import { Cause, Effect } from "effect"
export function refineRejection<A, E>(
evaluate: (signal: AbortSignal) => PromiseLike<A>,
refine: (cause: unknown) => E | undefined,
) {
return Effect.tryPromise(evaluate).pipe(
Effect.catch((error) => {
const cause = Cause.isUnknownError(error) ? error.cause : error
const refined = refine(cause)
if (refined !== undefined) return Effect.fail(refined)
return Effect.die(cause)
}),
)
}
export * as EffectPromise from "./promise"
+1 -1
View File
@@ -7,7 +7,7 @@ import {
printParseErrorCode, printParseErrorCode,
} from "jsonc-parser" } from "jsonc-parser"
import * as ConfigPaths from "@/config/paths" import { ConfigPaths } from "@/config/paths"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Flock } from "@opencode-ai/core/util/flock" import { Flock } from "@opencode-ai/core/util/flock"
+33 -32
View File
@@ -21,6 +21,7 @@ import { pathToFileURL } from "url"
import { Effect, Layer, Context, Schema, Types } from "effect" import { Effect, Layer, Context, Schema, Types } from "effect"
import { EffectBridge } from "@/effect/bridge" import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { EffectPromise } from "@/effect/promise"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema"
@@ -963,11 +964,24 @@ export function defaultModelIDs<T extends { models: Record<string, { id: string
return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) return mapValues(providers, (item) => sort(Object.values(item.models))[0].id)
} }
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {
static isInstance(input: unknown): input is ModelNotFoundError {
return input instanceof ModelNotFoundError
}
}
export type Error = ModelNotFoundError
export interface Interface { export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>> readonly list: () => Effect.Effect<Record<ProviderID, Info>>
readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info> readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model> readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3> readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
readonly closest: ( readonly closest: (
providerID: ProviderID, providerID: ProviderID,
query: string[], query: string[],
@@ -1638,14 +1652,14 @@ const layer = Layer.effect(
: fuzzysort : fuzzysort
.go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 }) .go(providerID, Object.keys({ ...s.catalog, ...s.providers }), { limit: 3, threshold: -10000 })
.map((m) => m.target) .map((m) => m.target)
throw new ModelNotFoundError({ providerID, modelID, suggestions }) return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
} }
const info = provider.models[modelID] const info = provider.models[modelID]
if (!info) { if (!info) {
const current = modelSuggestions(provider, modelID) const current = modelSuggestions(provider, modelID)
const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], modelID) const suggestions = current.length ? current : modelSuggestions(s.catalog[providerID], modelID)
throw new ModelNotFoundError({ providerID, modelID, suggestions }) return yield* new ModelNotFoundError({ providerID, modelID, suggestions })
} }
return info return info
}) })
@@ -1656,11 +1670,10 @@ const layer = Layer.effect(
const key = `${model.providerID}/${model.id}` const key = `${model.providerID}/${model.id}`
if (s.models.has(key)) return s.models.get(key)! if (s.models.has(key)) return s.models.get(key)!
return yield* Effect.promise(async () => { const provider = s.providers[model.providerID]
const provider = s.providers[model.providerID] return yield* EffectPromise.refineRejection(
const sdk = await resolveSDK(model, s, envs) async () => {
const sdk = await resolveSDK(model, s, envs)
try {
const language = s.modelLoaders[model.providerID] const language = s.modelLoaders[model.providerID]
? await s.modelLoaders[model.providerID](sdk, model.api.id, { ? await s.modelLoaders[model.providerID](sdk, model.api.id, {
...provider.options, ...provider.options,
@@ -1669,18 +1682,12 @@ const layer = Layer.effect(
: sdk.languageModel(model.api.id) : sdk.languageModel(model.api.id)
s.models.set(key, language) s.models.set(key, language)
return language return language
} catch (e) { },
if (e instanceof NoSuchModelError) (cause) =>
throw new ModelNotFoundError( cause instanceof NoSuchModelError
{ ? new ModelNotFoundError({ modelID: model.id, providerID: model.providerID, cause })
modelID: model.id, : undefined,
providerID: model.providerID, )
},
{ cause: e },
)
throw e
}
})
}) })
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) { const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
@@ -1700,7 +1707,7 @@ const layer = Layer.effect(
if (cfg.small_model) { if (cfg.small_model) {
const parsed = parseModel(cfg.small_model) const parsed = parseModel(cfg.small_model)
return yield* getModel(parsed.providerID, parsed.modelID) return yield* getModel(parsed.providerID, parsed.modelID).pipe(Effect.orDie)
} }
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
@@ -1728,22 +1735,22 @@ const layer = Layer.effect(
const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
const globalMatch = candidates.find((m) => m.startsWith("global.")) const globalMatch = candidates.find((m) => m.startsWith("global."))
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)) if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)).pipe(Effect.orDie)
const region = provider.options?.region const region = provider.options?.region
if (region) { if (region) {
const regionPrefix = region.split("-")[0] const regionPrefix = region.split("-")[0]
if (regionPrefix === "us" || regionPrefix === "eu") { if (regionPrefix === "us" || regionPrefix === "eu") {
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)) if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)).pipe(Effect.orDie)
} }
} }
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)) if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)).pipe(Effect.orDie)
} else { } else {
for (const model of Object.keys(provider.models)) { for (const model of Object.keys(provider.models)) {
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)) if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)).pipe(Effect.orDie)
} }
} }
} }
@@ -1818,12 +1825,6 @@ export function parseModel(model: string) {
} }
} }
export const ModelNotFoundError = NamedError.create("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
})
export const InitError = NamedError.create("ProviderInitError", { export const InitError = NamedError.create("ProviderInitError", {
providerID: ProviderID, providerID: ProviderID,
}) })
+6 -27
View File
@@ -617,14 +617,6 @@ function googleThinkingBudgetMax(apiId: string) {
return 24_576 return 24_576
} }
function googleSmallThinkingConfig(apiId: string) {
const levels = googleThinkingLevelEfforts(apiId)
if (apiId.toLowerCase().includes("gemini-3")) {
return { thinkingLevel: levels.includes("minimal") ? "minimal" : levels.includes("low") ? "low" : "high" }
}
return { thinkingBudget: googleThinkingBudgetMax(apiId) === 32_768 ? 128 : 0 }
}
export function variants(model: Provider.Model): Record<string, Record<string, any>> { export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {} if (!model.capabilities.reasoning) return {}
@@ -1184,40 +1176,27 @@ export function options(input: {
} }
export function smallOptions(model: Provider.Model) { export function smallOptions(model: Provider.Model) {
const small = Object.values(model.variants ?? {})[0] ?? {}
if ( if (
model.providerID === "openai" || model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot" model.api.npm === "@ai-sdk/github-copilot"
) { ) {
if (model.api.id.includes("gpt-5")) { const base = { store: false }
if (model.api.id.includes("-chat")) { return mergeDeep(base, small)
if (gpt5Version(model.api.id) === undefined) return { store: false }
return { store: false, reasoningEffort: "medium" }
}
if (model.api.id.includes("search-api")) return { store: false }
if (model.api.id.includes("5.") || model.api.id.includes("5-mini")) {
return { store: false, reasoningEffort: "low" }
}
return { store: false, reasoningEffort: "minimal" }
}
return { store: false }
}
if (model.providerID === "google") {
// gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget
return { thinkingConfig: googleSmallThinkingConfig(model.api.id) }
} }
if (model.providerID === "openrouter" || model.providerID === "llmgateway") { if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
if (model.api.id.includes("google")) { if (Object.keys(small).length === 0 && model.api.id.includes("google")) {
return { reasoning: { enabled: false } } return { reasoning: { enabled: false } }
} }
return { reasoningEffort: "minimal" }
} }
if (model.providerID === "venice") { if (model.providerID === "venice") {
if (Object.keys(small).length > 0) return small
return { veniceParameters: { disableThinking: true } } return { veniceParameters: { disableThinking: true } }
} }
return {} return small
} }
// Maps model ID prefix to provider slug used in providerOptions. // Maps model ID prefix to provider slug used in providerOptions.
@@ -1,5 +1,3 @@
import { Provider } from "@/provider/provider"
import { iife } from "@/util/iife"
import { NamedError } from "@opencode-ai/core/util/error" import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Cause, Effect } from "effect" import { Cause, Effect } from "effect"
@@ -23,14 +21,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect)
log.error("failed", { error, cause: Cause.pretty(cause) }) log.error("failed", { error, cause: Cause.pretty(cause) })
if (error instanceof NamedError) { if (error instanceof NamedError) {
return Effect.succeed( return Effect.succeed(HttpServerResponse.jsonUnsafe(error.toObject(), { status: 500 }))
HttpServerResponse.jsonUnsafe(error.toObject(), {
status: iife(() => {
if (error instanceof Provider.ModelNotFoundError) return 400
return 500
}),
}),
)
} }
return Effect.succeed( return Effect.succeed(
HttpServerResponse.jsonUnsafe( HttpServerResponse.jsonUnsafe(
+5 -3
View File
@@ -390,8 +390,8 @@ export const layer: Layer.Layer<
const agent = yield* agents.get("compaction") const agent = yield* agents.get("compaction")
const model = agent.model const model = agent.model
? yield* provider.getModel(agent.model.providerID, agent.model.modelID) ? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID) : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie)
const cfg = yield* config.get() const cfg = yield* config.get()
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
const prior = completedCompactions(history) const prior = completedCompactions(history)
@@ -519,7 +519,9 @@ export const layer: Layer.Layer<
{ {
sessionID: input.sessionID, sessionID: input.sessionID,
agent: userMessage.agent, agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID), model: yield* provider
.getModel(userMessage.model.providerID, userMessage.model.modelID)
.pipe(Effect.orDie),
provider: { provider: {
source: info.source, source: info.source,
info, info,
+6 -4
View File
@@ -1057,15 +1057,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (Exit.isSuccess(exit)) return exit.value if (Exit.isSuccess(exit)) return exit.value
const err = Cause.squash(exit.cause) const err = Cause.squash(exit.cause)
if (Provider.ModelNotFoundError.isInstance(err)) { if (Provider.ModelNotFoundError.isInstance(err)) {
const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : "" const hint = err.suggestions?.length ? ` Did you mean: ${err.suggestions.join(", ")}?` : ""
yield* bus.publish(Session.Event.Error, { yield* bus.publish(Session.Event.Error, {
sessionID, sessionID,
error: new NamedError.Unknown({ error: new NamedError.Unknown({
message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`, message: `Model not found: ${err.providerID}/${err.modelID}.${hint}`,
}).toObject(), }).toObject(),
}) })
} }
return yield* Effect.failCause(exit.cause) return yield* Effect.die(err)
}) })
const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) { const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) {
@@ -1108,7 +1108,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
const full = const full =
!input.variant && ag.variant && same !input.variant && ag.variant && same
? yield* provider.getModel(model.providerID, model.modelID).pipe(Effect.catchDefect(() => Effect.void)) ? yield* provider
.getModel(model.providerID, model.modelID)
.pipe(Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)))
: undefined : undefined
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined) const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
@@ -6,6 +6,7 @@ import { pathToFileURL } from "url"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config" import { Config } from "../../src/config/config"
import { ConfigPaths } from "../../src/config/paths"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { RuntimeFlags } from "../../src/effect/runtime-flags" import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
@@ -29,6 +30,7 @@ const configLayer = Config.layer.pipe(
Layer.provide(AuthTest.empty), Layer.provide(AuthTest.empty),
Layer.provide(AccountTest.empty), Layer.provide(AccountTest.empty),
Layer.provide(NpmTest.noop), Layer.provide(NpmTest.noop),
Layer.provide(ConfigPaths.defaultLayer),
) )
const pluginLayer = Plugin.layer.pipe( const pluginLayer = Plugin.layer.pipe(
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
+17
View File
@@ -64,6 +64,23 @@ describe("cli.error", () => {
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.") expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
}) })
test("formats legacy and tagged provider model errors the same way", () => {
const data = {
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestions: ["claude-sonnet-4"],
}
const expected = [
"Model not found: anthropic/claude-sonet-4",
"Did you mean: claude-sonnet-4",
"Try: `opencode models` to list available models",
"Or check your config (opencode.json) provider/model names",
].join("\n")
expect(FormatError({ name: "ProviderModelNotFoundError", data })).toBe(expected)
expect(FormatError({ _tag: "ProviderModelNotFoundError", ...data })).toBe(expected)
})
test("formats cancelled UI errors as empty output", () => { test("formats cancelled UI errors as empty output", () => {
expect(FormatError(new UI.CancelledError())).toBe("") expect(FormatError(new UI.CancelledError())).toBe("")
}) })
@@ -3,6 +3,7 @@ import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { ConfigManaged } from "@/config/managed" import { ConfigManaged } from "@/config/managed"
import { ConfigPaths } from "@/config/paths"
import { ConfigParse } from "../../src/config/parse" import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
@@ -57,6 +58,7 @@ const layer = Config.layer.pipe(
Layer.provide(emptyAccount), Layer.provide(emptyAccount),
Layer.provideMerge(infra), Layer.provideMerge(infra),
Layer.provide(noopNpm), Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
) )
const it = testEffect(layer) const it = testEffect(layer)
@@ -548,6 +550,7 @@ test("resolves env templates in account config with account token", async () =>
Layer.provide(fakeAccount), Layer.provide(fakeAccount),
Layer.provideMerge(infra), Layer.provideMerge(infra),
Layer.provide(noopNpm), Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
) )
try { try {
@@ -1059,6 +1062,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
Layer.provide(emptyAccount), Layer.provide(emptyAccount),
Layer.provideMerge(infra), Layer.provideMerge(infra),
Layer.provide(noopNpm), Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
) )
try { try {
@@ -1944,6 +1948,7 @@ test("project config overrides remote well-known config", async () => {
Layer.provide(emptyAccount), Layer.provide(emptyAccount),
Layer.provideMerge(infra), Layer.provideMerge(infra),
Layer.provide(noopNpm), Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
) )
try { try {
@@ -2002,6 +2007,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
Layer.provide(emptyAccount), Layer.provide(emptyAccount),
Layer.provideMerge(infra), Layer.provideMerge(infra),
Layer.provide(noopNpm), Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
) )
try { try {
@@ -2074,6 +2080,7 @@ test("wellknown remote_config supports templated env vars in headers", async ()
Layer.provide(emptyAccount), Layer.provide(emptyAccount),
Layer.provideMerge(infra), Layer.provideMerge(infra),
Layer.provide(noopNpm), Layer.provide(noopNpm),
Layer.provide(ConfigPaths.defaultLayer),
) )
try { try {
@@ -749,30 +749,39 @@ describe("workspace CRUD", () => {
}) })
}) })
test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => { it.instance(
await withInstance(async (dir) => { "remove deletes the workspace, associated sessions, adapter resources, and status",
const type = unique("remove-local") () => {
const recorded = localAdapter(path.join(dir, "remove-local")) return Effect.gen(function* () {
registerAdapter(Instance.project.id, type, recorded.adapter) const { directory: dir } = yield* TestInstance
const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) const instance = yield* InstanceRef
const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) if (!instance) return yield* Effect.die(new Error("missing test instance"))
const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const workspace = yield* Workspace.Service
attachSessionToWorkspace(one.id, info.id) const sessionSvc = yield* SessionNs.Service
attachSessionToWorkspace(two.id, info.id) const type = unique("remove-local")
const recorded = localAdapter(path.join(dir, "remove-local"))
registerAdapter(instance.project.id, type, recorded.adapter)
const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null })
const one = yield* sessionSvc.create({})
const two = yield* sessionSvc.create({})
attachSessionToWorkspace(one.id, info.id)
attachSessionToWorkspace(two.id, info.id)
const removed = await removeWorkspace(info.id) const removed = yield* workspace.remove(info.id)
expect(removed).toEqual(info) expect(removed).toEqual(info)
expect(await getWorkspace(info.id)).toBeUndefined() expect(yield* workspace.get(info.id)).toBeUndefined()
expect(recorded.calls.remove).toEqual([info]) expect(recorded.calls.remove).toEqual([info])
expect((await workspaceStatus()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined() expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBeUndefined()
expect( expect(
Database.use((db) => Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(), db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.workspace_id, info.id)).all(),
), ),
).toEqual([]) ).toEqual([])
}) })
}) },
{ git: true },
)
test("remove still deletes the row when the adapter cannot remove resources", async () => { test("remove still deletes the row when the adapter cannot remove resources", async () => {
await withInstance(async () => { await withInstance(async () => {
@@ -797,84 +806,111 @@ describe("workspace CRUD", () => {
}) })
}) })
test("sessionWarp moves a session into a local workspace and claims ownership", async () => { it.instance(
await withInstance(async (dir) => { "sessionWarp moves a session into a local workspace and claims ownership",
const previousType = unique("warp-prev-local") () => {
const targetType = unique("warp-target-local") return Effect.gen(function* () {
const previous = workspaceInfo(Instance.project.id, previousType) const { directory: dir } = yield* TestInstance
const target = workspaceInfo(Instance.project.id, targetType) const instance = yield* InstanceRef
insertWorkspace(previous) if (!instance) return yield* Effect.die(new Error("missing test instance"))
insertWorkspace(target) const workspace = yield* Workspace.Service
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter) const sessionSvc = yield* SessionNs.Service
registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter) const previousType = unique("warp-prev-local")
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const targetType = unique("warp-target-local")
attachSessionToWorkspace(session.id, previous.id) const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(instance.project.id, targetType)
insertWorkspace(previous)
insertWorkspace(target)
registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter)
registerAdapter(instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id }) yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id })
expect( expect(
Database.use((db) => Database.use((db) =>
db db
.select({ workspaceID: SessionTable.workspace_id }) .select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable) .from(SessionTable)
.where(eq(SessionTable.id, session.id)) .where(eq(SessionTable.id, session.id))
.get(), .get(),
)?.workspaceID, )?.workspaceID,
).toBe(target.id) ).toBe(target.id)
expect(sessionSequenceOwner(session.id)).toBe(target.id) expect(sessionSequenceOwner(session.id)).toBe(target.id)
}) })
}) },
{ git: true },
)
test("sessionWarp applies source workspace patch to local target workspace", async () => { it.instance(
await withInstance(async (dir) => { "sessionWarp applies source workspace patch to local target workspace",
const previousType = unique("warp-patch-prev-local") () => {
const targetType = unique("warp-patch-target-local") return Effect.gen(function* () {
const previousDir = path.join(dir, "warp-patch-prev-local") const { directory: dir } = yield* TestInstance
const targetDir = path.join(dir, "warp-patch-target-local") const instance = yield* InstanceRef
await initGitRepo(previousDir) if (!instance) return yield* Effect.die(new Error("missing test instance"))
await initGitRepo(targetDir) const workspace = yield* Workspace.Service
await fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n") const sessionSvc = yield* SessionNs.Service
await fs.writeFile(path.join(previousDir, "new.txt"), "new\n") const previousType = unique("warp-patch-prev-local")
const targetType = unique("warp-patch-target-local")
const previousDir = path.join(dir, "warp-patch-prev-local")
const targetDir = path.join(dir, "warp-patch-target-local")
yield* Effect.promise(() => initGitRepo(previousDir))
yield* Effect.promise(() => initGitRepo(targetDir))
yield* Effect.promise(() => fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(previousDir, "new.txt"), "new\n"))
const previous = workspaceInfo(Instance.project.id, previousType) const previous = workspaceInfo(instance.project.id, previousType)
const target = workspaceInfo(Instance.project.id, targetType) const target = workspaceInfo(instance.project.id, targetType)
insertWorkspace(previous) insertWorkspace(previous)
insertWorkspace(target) insertWorkspace(target)
registerAdapter(Instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter) registerAdapter(instance.project.id, previousType, localAdapter(previousDir, { createDir: false }).adapter)
registerAdapter(Instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter) registerAdapter(instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter)
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id) attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
expect(await fs.readFile(path.join(targetDir, "tracked.txt"), "utf8")).toBe("changed\n") expect(yield* Effect.promise(() => fs.readFile(path.join(targetDir, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(await fs.readFile(path.join(targetDir, "new.txt"), "utf8")).toBe("new\n") expect(yield* Effect.promise(() => fs.readFile(path.join(targetDir, "new.txt"), "utf8"))).toBe("new\n")
}) })
}) },
{ git: true },
)
test("sessionWarp detaches a session to the local project and claims project ownership", async () => { it.instance(
await withInstance(async (dir) => { "sessionWarp detaches a session to the local project and claims project ownership",
const previousType = unique("warp-detach-local") () => {
const previous = workspaceInfo(Instance.project.id, previousType) return Effect.gen(function* () {
insertWorkspace(previous) const { directory: dir } = yield* TestInstance
registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter) const instance = yield* InstanceRef
const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) if (!instance) return yield* Effect.die(new Error("missing test instance"))
attachSessionToWorkspace(session.id, previous.id) const workspace = yield* Workspace.Service
const sessionSvc = yield* SessionNs.Service
const previousType = unique("warp-detach-local")
const previous = workspaceInfo(instance.project.id, previousType)
insertWorkspace(previous)
registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter)
const session = yield* sessionSvc.create({})
attachSessionToWorkspace(session.id, previous.id)
await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) yield* workspace.sessionWarp({ workspaceID: null, sessionID: session.id })
expect( expect(
Database.use((db) => Database.use((db) =>
db db
.select({ workspaceID: SessionTable.workspace_id }) .select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable) .from(SessionTable)
.where(eq(SessionTable.id, session.id)) .where(eq(SessionTable.id, session.id))
.get(), .get(),
)?.workspaceID, )?.workspaceID,
).toBeNull() ).toBeNull()
expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id) expect(sessionSequenceOwner(session.id)).toBe(instance.project.id)
}) })
}) },
{ git: true },
)
test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => { test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => {
await withInstance(async () => { await withInstance(async () => {
@@ -1572,8 +1572,8 @@ test("ModelNotFoundError includes suggestions for typos", async () => {
await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet
expect(true).toBe(false) // Should not reach here expect(true).toBe(false) // Should not reach here
} catch (e: any) { } catch (e: any) {
expect(e.data.suggestions).toBeDefined() expect(e.suggestions).toBeDefined()
expect(e.data.suggestions.length).toBeGreaterThan(0) expect(e.suggestions.length).toBeGreaterThan(0)
} }
}, },
}) })
@@ -1598,8 +1598,8 @@ test("ModelNotFoundError for provider includes suggestions", async () => {
await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic
expect(true).toBe(false) // Should not reach here expect(true).toBe(false) // Should not reach here
} catch (e: any) { } catch (e: any) {
expect(e.data.suggestions).toBeDefined() expect(e.suggestions).toBeDefined()
expect(e.data.suggestions).toContain("anthropic") expect(e.suggestions).toContain("anthropic")
} }
}, },
}) })
@@ -1625,7 +1625,7 @@ test("ModelNotFoundError suggests catalog models for unloaded providers", async
throw new Error("expected model lookup to fail") throw new Error("expected model lookup to fail")
} catch (e) { } catch (e) {
if (!Provider.ModelNotFoundError.isInstance(e)) throw e if (!Provider.ModelNotFoundError.isInstance(e)) throw e
expect(e.data.suggestions).toContain("claude-haiku-4-5") expect(e.suggestions).toContain("claude-haiku-4-5")
} }
}, },
}) })
@@ -3602,8 +3602,8 @@ describe("ProviderTransform.variants", () => {
}) })
describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
const createModel = (apiId: string) => const createModel = (apiId: string) => {
({ const model = {
id: `openai/${apiId}`, id: `openai/${apiId}`,
providerID: "openai", providerID: "openai",
api: { api: {
@@ -3611,13 +3611,43 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
url: "https://api.openai.com", url: "https://api.openai.com",
npm: "@ai-sdk/openai", npm: "@ai-sdk/openai",
}, },
}) as any capabilities: { reasoning: true },
limit: { output: 64_000 },
release_date: "2026-01-01",
} as any
model.variants = ProviderTransform.variants(model)
return model
}
for (const testCase of [ for (const testCase of [
{ id: "gpt-5-chat-latest", options: { store: false } }, { id: "gpt-5-chat-latest", options: { store: false } },
{ id: "gpt-5.1-chat-latest", options: { store: false, reasoningEffort: "medium" } }, {
{ id: "gpt-5.2-chat-latest", options: { store: false, reasoningEffort: "medium" } }, id: "gpt-5.1-chat-latest",
{ id: "gpt-5-search-api", options: { store: false } }, options: {
store: false,
reasoningEffort: "medium",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
{
id: "gpt-5.2-chat-latest",
options: {
store: false,
reasoningEffort: "medium",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
{
id: "gpt-5-search-api",
options: {
store: false,
reasoningEffort: "none",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
]) { ]) {
test(`${testCase.id} returns only supported small options`, () => { test(`${testCase.id} returns only supported small options`, () => {
expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options) expect(ProviderTransform.smallOptions(createModel(testCase.id))).toEqual(testCase.options)
@@ -3626,8 +3656,8 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => {
}) })
describe("ProviderTransform.smallOptions - google thinking controls", () => { describe("ProviderTransform.smallOptions - google thinking controls", () => {
const createGoogleModel = (apiId: string) => const createGoogleModel = (apiId: string) => {
({ const model = {
id: `google/${apiId}`, id: `google/${apiId}`,
providerID: "google", providerID: "google",
api: { api: {
@@ -3635,20 +3665,44 @@ describe("ProviderTransform.smallOptions - google thinking controls", () => {
url: "https://generativelanguage.googleapis.com", url: "https://generativelanguage.googleapis.com",
npm: "@ai-sdk/google", npm: "@ai-sdk/google",
}, },
}) as any capabilities: { reasoning: true },
limit: { output: 64_000 },
} as any
model.variants = ProviderTransform.variants(model)
return model
}
for (const testCase of [ for (const testCase of [
{ id: "gemini-3-pro-preview", options: { thinkingConfig: { thinkingLevel: "low" } } }, { id: "gemini-3-pro-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "low" } } },
{ id: "gemini-3-flash-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } }, { id: "gemini-3-flash-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" } } },
{ id: "gemini-3.1-flash-image-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } }, {
{ id: "gemini-3-pro-image-preview", options: { thinkingConfig: { thinkingLevel: "high" } } }, id: "gemini-3.1-flash-image-preview",
{ id: "gemini-2.5-pro", options: { thinkingConfig: { thinkingBudget: 128 } } }, options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "minimal" } },
{ id: "gemini-2.5-flash", options: { thinkingConfig: { thinkingBudget: 0 } } }, },
{ id: "gemini-3-pro-image-preview", options: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } } },
{ id: "gemini-2.5-pro", options: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
{ id: "gemini-2.5-flash", options: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
]) { ]) {
test(`${testCase.id} returns supported small thinking options`, () => { test(`${testCase.id} returns supported small thinking options`, () => {
expect(ProviderTransform.smallOptions(createGoogleModel(testCase.id))).toEqual(testCase.options) expect(ProviderTransform.smallOptions(createGoogleModel(testCase.id))).toEqual(testCase.options)
}) })
} }
test("uses the first configured variant when available", () => {
expect(
ProviderTransform.smallOptions({
...createGoogleModel("gemini-2.5-pro"),
variants: {
high: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
max: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32768 } },
},
}),
).toEqual({ thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } })
})
test("does not synthesize thinking options when variants are empty", () => {
expect(ProviderTransform.smallOptions({ ...createGoogleModel("gemini-2.5-pro"), variants: {} })).toEqual({})
})
}) })
describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { describe("ProviderTransform.providerOptions - ai-gateway-provider", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.14.48", "version": "1.14.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.14.48", "version": "1.14.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.14.48", "version": "1.14.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.14.48", "version": "1.14.49",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.14.48", "version": "1.14.49",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.14.48", "version": "1.14.49",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",