From 0aa4031dd0ee1db4d54a979e10fd264144c46d47 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 20 Jul 2026 15:18:03 -0400 Subject: [PATCH] refactor(core): inject models options --- packages/cli/src/server-process.ts | 5 + packages/core/src/database/database.ts | 2 +- packages/core/src/models-dev.ts | 25 +- packages/core/test/models.test.ts | 35 +- packages/core/test/plugin/models-dev.test.ts | 754 +++++++++---------- packages/sdk-next/src/opencode.ts | 8 +- packages/server/src/process.ts | 3 + packages/server/src/routes.ts | 7 +- 8 files changed, 396 insertions(+), 443 deletions(-) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 30504dc03f..0443aab736 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -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 diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 4693cfa136..c3ab69baa0 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -58,6 +58,6 @@ export function layer(options?: Options) { export const node = makeGlobalNode({ service: Service, - layer: layer(), + layer: layer({ path: ":memory:" }), deps: [], }) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 0caf414cce..6be577b55b 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -530,9 +530,15 @@ export interface Interface { readonly refresh: (force?: boolean) => Effect.Effect } +export interface Options { + readonly url?: string + readonly file?: string + readonly fetch?: boolean +} + export class Service extends Context.Service()("@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), 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" diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 638cfc463e..1733b9b92c 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -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) => }), ) -const buildLayer = (state: Ref.Ref) => +const buildLayer = (state: Ref.Ref, 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) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index f07c96b439..8bc84306bf 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -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")))), ) }) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 6c376beeaf..34191aee5a 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -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), ), ), diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 3e66ec9965..8baa065aa2 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -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 = { 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* (options: return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname) }, database: options.database, + models: options.models, }).pipe(Layer.provide(NodeHttpServer.layerHttpServices)), applicationScope, ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 0b19e2a17f..380c70f700 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -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 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 = {}) { +export function createEmbeddedRoutes(options: Pick = {}) { return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options) } function makeRoutes( auth: Layer.Layer, - options: Pick, + options: Pick, ) { 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)], ]