refactor(core): inject models options

This commit is contained in:
Dax Raad
2026-07-20 15:18:39 -04:00
parent f971aa0719
commit 0aa4031dd0
8 changed files with 396 additions and 443 deletions
+5
View File
@@ -68,6 +68,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
database: {
path: process.env.OPENCODE_DB,
},
models: {
url: process.env.OPENCODE_MODELS_URL,
file: process.env.OPENCODE_MODELS_PATH,
fetch: !["1", "true"].includes(process.env.OPENCODE_DISABLE_MODELS_FETCH?.toLowerCase() ?? ""),
},
service:
serviceOptions === undefined
? undefined
+1 -1
View File
@@ -58,6 +58,6 @@ export function layer(options?: Options) {
export const node = makeGlobalNode({
service: Service,
layer: layer(),
layer: layer({ path: ":memory:" }),
deps: [],
})
+18 -7
View File
@@ -530,9 +530,15 @@ export interface Interface {
readonly refresh: (force?: boolean) => Effect.Effect<void>
}
export interface Options {
readonly url?: string
readonly file?: string
readonly fetch?: boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const layer = Layer.effect(
export const layer = (options?: Options) => Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
@@ -547,7 +553,8 @@ const layer = Layer.effect(
),
)
const source = Flag.OPENCODE_MODELS_URL || "https://models.dev"
const source = options?.url ?? "https://models.dev"
const fetch = options?.fetch ?? true
const filepath = path.join(
Global.Path.cache,
source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`,
@@ -571,11 +578,11 @@ const layer = Layer.effect(
)
})
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe(
Effect.map((input) => input as Record<string, SourceProvider>),
Effect.catch((error) => {
if (
Flag.OPENCODE_MODELS_PATH === undefined &&
options?.file === undefined &&
error._tag === "FileSystemError" &&
error.method === "readJson"
) {
@@ -609,7 +616,7 @@ const layer = Layer.effect(
if (fromDisk) return normalize(fromDisk)
const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled)
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return []
if (!fetch) return []
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
Effect.gen(function* () {
@@ -642,7 +649,7 @@ const layer = Layer.effect(
)
})
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
if (fetch && !process.argv.includes("--get-yargs-completions")) {
// Schedule.spaced runs the effect once, then waits between completions.
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
}
@@ -651,6 +658,10 @@ const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export function nodeWith(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [FSUtil.node, EventV2.node, httpClient] })
}
export const node = nodeWith()
export * as ModelsDev from "./models-dev"
+5 -30
View File
@@ -1,11 +1,10 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { describe, expect, beforeEach, afterAll } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@@ -14,22 +13,6 @@ import { it } from "./lib/effect"
import { readFile, rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can
// resolve providers without network. These tests need to drive the on-disk
// cache themselves and silence the eager refresh fork. Save/restore around
// the suite — never leak the mutation to subsequent test files in the same
// bun process.
const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH
const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH
beforeAll(() => {
Flag.OPENCODE_MODELS_PATH = undefined
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
})
afterAll(() => {
Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH
Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH
})
const cacheFile = path.join(Global.Path.cache, "models.json")
const fixture = {
@@ -172,12 +155,13 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
}),
)
const buildLayer = (state: Ref.Ref<MockState>) =>
const buildLayer = (state: Ref.Ref<MockState>, options: ModelsDev.Options = { fetch: false }) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.nodeWith(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
]),
)
@@ -243,17 +227,8 @@ describe("ModelsDev Service", () => {
Effect.gen(function* () {
yield* writeCacheText("{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state))
const result = yield* Effect.acquireUseRelease(
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = false
}),
() => ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context)),
() =>
Effect.sync(() => {
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
}),
)
const context = yield* Layer.build(buildLayer(state, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(yield* Effect.promise(() => readFile(cacheFile, "utf8"))).toBe(JSON.stringify(fixture2))
const final = yield* Ref.get(state)
+353 -401
View File
@@ -7,7 +7,6 @@ import { Integration } from "@opencode-ai/core/integration"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
@@ -26,6 +25,8 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
[Location.node, locationLayer],
])
const it = testEffect(layer)
const models = (file: string) =>
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.nodeWith({ file, fetch: false })]])
describe("ModelsDevPlugin", () => {
it.effect("projects normalized models.dev snapshots into the catalog", () =>
@@ -193,410 +194,361 @@ describe("ModelsDevPlugin", () => {
)
it.effect("registers key methods for providers with environment variables", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
)
expect(yield* integrations.list()).toEqual([
Integration.Info.make({
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
},
],
connections: [],
}),
])
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
Effect.gen(function* () {
const integrations = yield* Integration.Service
const catalog = yield* Catalog.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
),
)
expect(yield* integrations.list()).toEqual([
Integration.Info.make({
id: Integration.ID.make("acme"),
name: "Acme",
methods: [
{ type: "key" },
{
type: "env",
names: ["ACME_API_KEY"],
},
],
connections: [],
}),
])
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))),
)
it.effect("converts reasoning options into settings variants", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
path: Flag.OPENCODE_MODELS_PATH,
disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH,
}
Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")
Flag.OPENCODE_DISABLE_MODELS_FETCH = true
return previous
}),
() =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
)
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
})
expect(mode?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro"))
expect(pro).toMatchObject({
id: "gpt-reasoning-pro",
body: { reasoning: { mode: "pro" } },
})
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* catalog.model.get(
ProviderV2.ID.anthropic,
ModelV2.ID.make("claude-opus-4.7"),
)
expect(anthropicEffortModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
])
const anthropicToggleModel = yield* catalog.model.get(
ProviderV2.ID.anthropic,
ModelV2.ID.make("claude-toggle"),
)
expect(anthropicToggleModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5"))
expect(opus45?.variants).toEqual([
{ id: ModelV2.VariantID.make("low"), settings: { effort: "low" } },
{ id: ModelV2.VariantID.make("high"), settings: { effort: "high" } },
])
const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5"))
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: ModelV2.VariantID.make(id),
settings: { reasoningEffort: id },
})),
)
const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3"))
expect(minimax?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only"))
expect(toggle?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
])
const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget"))
expect(combined?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle"))
expect(gateway?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gatewayNova = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("amazon/nova-2-lite"),
)
expect(gatewayNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const gatewayFallback = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("deepseek/deepseek-toggle"),
)
expect(gatewayFallback?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningEffort: "low" },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
},
])
const openrouter = yield* catalog.model.get(
ProviderV2.ID.make("openrouter"),
ModelV2.ID.make("openrouter-toggle"),
)
expect(openrouter?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
])
const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash"))
expect(google?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const vertex = yield* catalog.model.get(
ProviderV2.ID.make("google-vertex"),
ModelV2.ID.make("gemini-2.5-flash-lite"),
)
expect(vertex?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const bedrock = yield* catalog.model.get(
ProviderV2.ID.make("amazon-bedrock"),
ModelV2.ID.make("amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const sapGemini = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("gemini-2.5-flash"),
)
expect(sapGemini?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
},
])
const sapNova = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("amazon--nova-lite"),
)
expect(sapNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
},
},
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
},
])
const sapCohere = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("cohere--command-a-reasoning"),
)
expect(sapCohere?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { modelParams: { reasoning_effort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { reasoning_effort: "high" } },
},
])
const sapAnthropicEffort = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4.7-opus"),
)
expect(sapAnthropicEffort?.variants).toEqual([
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
},
},
},
},
])
const sapAnthropicBudget = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4-sonnet"),
)
expect(sapAnthropicBudget?.variants).toEqual([
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
},
},
},
{
id: ModelV2.VariantID.make("max"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },
},
},
},
])
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
Flag.OPENCODE_MODELS_PATH = previous.path
Flag.OPENCODE_DISABLE_MODELS_FETCH = previous.disabled
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
yield* ModelsDevPlugin.effect(
host({
catalog: catalogHost(catalog),
integration: integrationHost(integrations),
}),
),
)
const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning"))
expect(model?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("low"),
settings: {
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
expect(model?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
})
const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high"))
expect(mode).toMatchObject({
id: "gpt-reasoning-high",
name: "GPT Reasoning High",
headers: { "x-mode": "high" },
body: { service_tier: "priority" },
})
expect(mode?.variants?.map((variant) => variant.id)).toEqual([
ModelV2.VariantID.make("low"),
ModelV2.VariantID.make("high"),
])
const pro = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-pro"))
expect(pro).toMatchObject({
id: "gpt-reasoning-pro",
body: { reasoning: { mode: "pro" } },
})
const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4.7"))
expect(anthropicEffortModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("low"),
settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
},
])
const anthropicToggleModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-toggle"))
expect(anthropicToggleModel?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const opus45 = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-opus-4-5"))
expect(opus45?.variants).toEqual([
{ id: ModelV2.VariantID.make("low"), settings: { effort: "low" } },
{ id: ModelV2.VariantID.make("high"), settings: { effort: "high" } },
])
const grok = yield* catalog.model.get(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4.5"))
expect(grok?.variants).toEqual(
["low", "medium", "high"].map((id) => ({
id: ModelV2.VariantID.make(id),
settings: { reasoningEffort: id },
})),
)
const minimax = yield* catalog.model.get(ProviderV2.ID.make("opencode-go"), ModelV2.ID.make("minimax-m3"))
expect(minimax?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { thinking: { type: "disabled" } } },
{
id: ModelV2.VariantID.make("thinking"),
settings: { thinking: { type: "adaptive", display: "summarized" } },
},
])
const toggle = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-only"))
expect(toggle?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{ id: ModelV2.VariantID.make("thinking"), settings: { enableThinking: true } },
])
const combined = yield* catalog.model.get(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("toggle-budget"))
expect(combined?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gateway = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("alibaba/qwen-toggle"))
expect(gateway?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { enableThinking: false } },
{
id: ModelV2.VariantID.make("high"),
settings: { enableThinking: true, thinkingBudget: 8000 },
},
{
id: ModelV2.VariantID.make("max"),
settings: { enableThinking: true, thinkingBudget: 16000 },
},
])
const gatewayNova = yield* catalog.model.get(ProviderV2.ID.make("vercel"), ModelV2.ID.make("amazon/nova-2-lite"))
expect(gatewayNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const gatewayFallback = yield* catalog.model.get(
ProviderV2.ID.make("vercel"),
ModelV2.ID.make("deepseek/deepseek-toggle"),
)
expect(gatewayFallback?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { reasoning: { enabled: false } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningEffort: "low" },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningEffort: "high" },
},
])
const openrouter = yield* catalog.model.get(
ProviderV2.ID.make("openrouter"),
ModelV2.ID.make("openrouter-toggle"),
)
expect(openrouter?.variants).toEqual([
{ id: ModelV2.VariantID.make("none"), settings: { reasoning: { enabled: false } } },
{ id: ModelV2.VariantID.make("thinking"), settings: { reasoning: { enabled: true } } },
])
const google = yield* catalog.model.get(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini-2.5-flash"))
expect(google?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const vertex = yield* catalog.model.get(
ProviderV2.ID.make("google-vertex"),
ModelV2.ID.make("gemini-2.5-flash-lite"),
)
expect(vertex?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } },
},
])
const bedrock = yield* catalog.model.get(
ProviderV2.ID.make("amazon-bedrock"),
ModelV2.ID.make("amazon.nova-2-lite-v1:0"),
)
expect(bedrock?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { additionalModelRequestFields: { reasoningConfig: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } },
},
])
const sapGemini = yield* catalog.model.get(ProviderV2.ID.make("sap-ai-core"), ModelV2.ID.make("gemini-2.5-flash"))
expect(sapGemini?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinkingConfig: { includeThoughts: false, thinkingBudget: 0 } } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 8000 } } },
},
{
id: ModelV2.VariantID.make("max"),
settings: { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16000 } } },
},
])
const sapNova = yield* catalog.model.get(ProviderV2.ID.make("sap-ai-core"), ModelV2.ID.make("amazon--nova-lite"))
expect(sapNova?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: {
modelParams: { additionalModelRequestFields: { thinking: { type: "disabled" } } },
},
},
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "low" } } },
},
},
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: { additionalModelRequestFields: { output_config: { effort: "high" } } },
},
},
])
const sapCohere = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("cohere--command-a-reasoning"),
)
expect(sapCohere?.variants).toEqual([
{
id: ModelV2.VariantID.make("none"),
settings: { modelParams: { thinking: { type: "disabled" } } },
},
{
id: ModelV2.VariantID.make("low"),
settings: { modelParams: { reasoning_effort: "low" } },
},
{
id: ModelV2.VariantID.make("high"),
settings: { modelParams: { reasoning_effort: "high" } },
},
])
const sapAnthropicEffort = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4.7-opus"),
)
expect(sapAnthropicEffort?.variants).toEqual([
{
id: ModelV2.VariantID.make("low"),
settings: {
modelParams: {
additionalModelRequestFields: {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
},
},
},
},
])
const sapAnthropicBudget = yield* catalog.model.get(
ProviderV2.ID.make("sap-ai-core"),
ModelV2.ID.make("anthropic--claude-4-sonnet"),
)
expect(sapAnthropicBudget?.variants).toEqual([
{
id: ModelV2.VariantID.make("high"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 8000 } },
},
},
},
{
id: ModelV2.VariantID.make("max"),
settings: {
modelParams: {
additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: 16000 } },
},
},
},
])
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json")))),
)
})
+6 -2
View File
@@ -1,15 +1,19 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { Database } from "@opencode-ai/core/database/database"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
export const create = Effect.fn("OpenCode.create")(function* (options: { readonly database?: Database.Options } = {}) {
export const create = Effect.fn("OpenCode.create")(function* (options: {
readonly database?: Database.Options
readonly models?: ModelsDev.Options
} = {}) {
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
createEmbeddedRoutes({ database: { path: ":memory:", ...options.database } }).pipe(
createEmbeddedRoutes({ database: { path: ":memory:", ...options.database }, models: options.models }).pipe(
Layer.provide(HttpServer.layerServices),
),
),
+3
View File
@@ -3,6 +3,7 @@ export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { Database } from "@opencode-ai/core/database/database"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
@@ -22,6 +23,7 @@ export type Options<E = never, R = never> = {
readonly password: string
readonly instanceID: string
readonly database?: Database.Options
readonly models?: ModelsDev.Options
readonly service?: {
readonly onListen: (
address: HttpServer.Address,
@@ -77,6 +79,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname)
},
database: options.database,
models: options.models,
}).pipe(Layer.provide(NodeHttpServer.layerHttpServices)),
applicationScope,
)
+5 -2
View File
@@ -12,6 +12,7 @@ import { Project } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Job } from "@opencode-ai/core/job"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
@@ -55,6 +56,7 @@ export interface Options {
readonly password?: string
readonly serviceURLs?: () => ReadonlyArray<string>
readonly database?: Database.Options
readonly models?: ModelsDev.Options
}
export function createRoutes(options: Options = {}) {
@@ -66,17 +68,18 @@ export function createRoutes(options: Options = {}) {
)
}
export function createEmbeddedRoutes(options: Pick<Options, "database"> = {}) {
export function createEmbeddedRoutes(options: Pick<Options, "database" | "models"> = {}) {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options)
}
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
options: Pick<Options, "database" | "serviceURLs">,
options: Pick<Options, "database" | "models" | "serviceURLs">,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[Database.node, Database.layer(options.database)],
[ModelsDev.node, ModelsDev.nodeWith(options.models)],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
]