From 85fdb3bde6de87387cf143a58d6d45c76b66d3e8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 9 Jul 2026 13:02:01 -0400 Subject: [PATCH] refactor(core): optimize location startup --- packages/core/src/agent.ts | 1 + packages/core/src/catalog.ts | 26 +- packages/core/src/command.ts | 1 + packages/core/src/integration.ts | 1 + packages/core/src/models-dev.ts | 393 +++++++++++++++++-------- packages/core/src/plugin/models-dev.ts | 247 +--------------- packages/core/src/reference.ts | 1 + packages/core/src/skill.ts | 1 + packages/core/src/state.ts | 7 +- packages/schema/src/agent.ts | 6 +- packages/schema/src/model.ts | 6 +- packages/schema/src/provider.ts | 4 +- 12 files changed, 316 insertions(+), 378 deletions(-) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 8ba976399d..822eab9a4a 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -52,6 +52,7 @@ const layer = Layer.effect( Effect.gen(function* () { const events = yield* EventV2.Service const state = State.create({ + name: "agent", initial: () => ({ agents: new Map() }), draft: (draft) => ({ list: () => Array.fromIterable(draft.agents.values()) as Info[], diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 2441f03789..c12793b358 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -72,16 +72,17 @@ const layer = Layer.effect( } const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { - return ModelV2.Info.make({ + return { ...model, package: model.package ?? provider.package, settings: ProviderV2.mergeOverlay(provider.settings, model.settings), headers: ProviderV2.mergeHeaders(provider.headers, model.headers), body: ProviderV2.mergeOverlay(provider.body, model.body), - }) + } satisfies ModelV2.Info } const state = State.create({ + name: "catalog", initial: () => ({ providers: new Map() }), draft: (draft) => { const result: Draft = { @@ -179,7 +180,18 @@ const layer = Layer.effect( available: Effect.fn("CatalogV2.model.available")(function* () { const providers = new Set((yield* result.provider.available()).map((provider) => provider.id)) - return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled) + const models: ModelV2.Info[] = [] + for (const record of state.get().providers.values()) { + if (!providers.has(record.provider.id)) continue + for (const model of record.models.values()) { + if (!model.enabled) continue + models.push(projectModel(model, record.provider)) + } + } + return pipe( + models, + Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), + ) }), default: Effect.fn("CatalogV2.model.default")(function* () { @@ -192,13 +204,7 @@ const layer = Layer.effect( } } - return Option.getOrUndefined( - pipe( - yield* result.model.available(), - Array.sortWith((item) => item.time.released, Order.flip(Order.Number)), - Array.head, - ), - ) + return (yield* result.model.available())[0] }), small: Effect.fn("CatalogV2.model.small")(function* (providerID) { diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 44f3138ba7..ff3ffe353e 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -61,6 +61,7 @@ const layer = Layer.effect( const config = yield* Config.Service const location = yield* Location.Service const state = State.create({ + name: "command", initial: () => ({ commands: new Map() }), draft: (draft) => ({ list: () => Array.from(draft.commands.values()) as Info[], diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index c4f7c3b8f2..4e57d390d1 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -227,6 +227,7 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const attempts = SynchronizedRef.makeUnsafe(new Map()) const state = State.create({ + name: "integration", initial: () => ({ integrations: new Map() }), draft: (draft) => ({ list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[], diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index faeee213fb..3de39f670c 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -1,5 +1,5 @@ import path from "path" -import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { Context, Duration, Effect, Layer, Option, Schedule } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" import { Money } from "@opencode-ai/schema/money" @@ -12,130 +12,284 @@ import { InstallationChannel, InstallationVersion } from "./installation/version import { EventV2 } from "./event" import { makeGlobalNode } from "./effect/app-node" import { httpClient } from "./effect/app-node-platform" +import { ModelV2 } from "./model" +import { ProviderV2 } from "./provider" -export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) -export type CatalogModelStatus = typeof CatalogModelStatus.Type +export type CatalogModelStatus = "alpha" | "beta" | "deprecated" const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` -const CostTier = Schema.Struct({ - input: Money.USDPerMillionTokens, - output: Money.USDPerMillionTokens, - cache_read: Schema.optional(Money.USDPerMillionTokens), - cache_write: Schema.optional(Money.USDPerMillionTokens), - tier: Schema.Struct({ - type: Schema.Literal("context"), - size: Schema.Finite, - }), -}) +type Cost = { + readonly input: Money.USDPerMillionTokens + readonly output: Money.USDPerMillionTokens + readonly cache_read?: Money.USDPerMillionTokens + readonly cache_write?: Money.USDPerMillionTokens + readonly tiers?: readonly (Cost & { readonly tier: { readonly type: "context"; readonly size: number } })[] + readonly context_over_200k?: Omit +} -const Cost = Schema.Struct({ - input: Money.USDPerMillionTokens, - output: Money.USDPerMillionTokens, - cache_read: Schema.optional(Money.USDPerMillionTokens), - cache_write: Schema.optional(Money.USDPerMillionTokens), - tiers: Schema.optional(Schema.Array(CostTier)), - context_over_200k: Schema.optional( - Schema.Struct({ - input: Money.USDPerMillionTokens, - output: Money.USDPerMillionTokens, - cache_read: Schema.optional(Money.USDPerMillionTokens), - cache_write: Schema.optional(Money.USDPerMillionTokens), - }), - ), -}) +type ReasoningOption = + | { readonly type: "effort"; readonly values: readonly (string | null)[] } + | { readonly type: "toggle" } + | { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number } -const ReasoningOption = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("effort"), - values: Schema.Array(Schema.Union([Schema.String, Schema.Null])), - }), - Schema.Struct({ - type: Schema.Literal("toggle"), - }), - Schema.Struct({ - type: Schema.Literal("budget_tokens"), - min: Schema.optional(Schema.Finite), - max: Schema.optional(Schema.Finite), - }), -]) +type Modality = "text" | "audio" | "image" | "video" | "pdf" -export const Model = Schema.Struct({ - id: Schema.String, - name: Schema.String, - family: Schema.optional(Schema.String), - release_date: Schema.String, - attachment: Schema.Boolean, - reasoning: Schema.Boolean, - reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), - temperature: Schema.optional(Schema.Boolean), - tool_call: Schema.Boolean, - interleaved: Schema.optional( - Schema.Union([ - Schema.Literal(true), - Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), - }), - ]), - ), - cost: Schema.optional(Cost), - limit: Schema.Struct({ - context: Schema.Finite, - input: Schema.optional(Schema.Finite), - output: Schema.Finite, - }), - modalities: Schema.optional( - Schema.Struct({ - input: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])), - output: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])), - }), - ), - experimental: Schema.optional( - Schema.Struct({ - modes: Schema.optional( - Schema.Record( - Schema.String, - Schema.Struct({ - cost: Schema.optional(Cost), - provider: Schema.optional( - Schema.Struct({ - body: Schema.optional(Schema.Record(Schema.String, Schema.MutableJson)), - headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), - }), - ), +type SourceModel = { + readonly id: string + readonly name: string + readonly family?: string + readonly release_date: string + readonly attachment: boolean + readonly reasoning: boolean + readonly reasoning_options?: readonly ReasoningOption[] + readonly temperature?: boolean + readonly tool_call: boolean + readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" } + readonly cost?: Cost + readonly limit: { readonly context: number; readonly input?: number; readonly output: number } + readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] } + readonly experimental?: { + readonly modes?: Readonly< + Record< + string, + { + readonly cost?: Cost + readonly provider?: { readonly body?: ProviderV2.Settings; readonly headers?: Readonly> } + } + > + > + } + readonly status?: CatalogModelStatus + readonly provider?: { readonly npm?: string; readonly api?: string } +} + +type SourceProvider = { + readonly api?: string + readonly name: string + readonly env: readonly string[] + readonly id: string + readonly npm?: string + readonly models: Readonly> +} + +export type Snapshot = { + readonly info: ProviderV2.Info + readonly models: readonly ModelV2.Info[] + readonly environment: readonly string[] +} + +function normalize(input: Record): readonly Snapshot[] { + const providers: Snapshot[] = [] + for (const item of Object.values(input)) { + const providerID = ProviderV2.ID.make(item.id) + const info = { + id: providerID, + name: item.name, + package: item.npm ? ProviderV2.aisdk(item.npm) : "", + ...(item.api ? { settings: { baseURL: item.api } } : {}), + } satisfies ProviderV2.Info + const models: ModelV2.Info[] = [] + for (const model of Object.values(item.models)) { + const baseCost = cost(model.cost) + const variants = reasoningVariants(item, model) + const id = ModelV2.ID.make(model.id) + models.push(modelInfo(providerID, id, model, { cost: baseCost, variants })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + const modeID = ModelV2.ID.make(`${model.id}-${mode}`) + models.push( + modelInfo(providerID, modeID, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + variants, }), - ), - ), - }), - ), - status: Schema.optional(CatalogModelStatus), - provider: Schema.optional( - Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), - ), -}) -export type Model = Schema.Schema.Type + ) + } + } + providers.push({ info, models, environment: [...item.env] }) + } + return providers +} -export const Provider = Schema.Struct({ - api: Schema.optional(Schema.String), - name: Schema.String, - env: Schema.Array(Schema.String), - id: Schema.String, - npm: Schema.optional(Schema.String), - models: Schema.Record(Schema.String, Model), -}) +function released(date: string) { + const time = Date.parse(date) + return Number.isFinite(time) ? time : 0 +} -export type Provider = Schema.Schema.Type +function cost(input: SourceModel["cost"]): ModelV2.Info["cost"] { + const base = { + input: input?.input ?? Money.USDPerMillionTokens.zero, + output: input?.output ?? Money.USDPerMillionTokens.zero, + cache: { + read: input?.cache_read ?? Money.USDPerMillionTokens.zero, + write: input?.cache_write ?? Money.USDPerMillionTokens.zero, + }, + } + return [ + base, + ...(input?.tiers?.map((item) => ({ + tier: item.tier, + input: item.input, + output: item.output, + cache: { + read: item.cache_read ?? Money.USDPerMillionTokens.zero, + write: item.cache_write ?? Money.USDPerMillionTokens.zero, + }, + })) ?? []), + ...(input?.context_over_200k + ? [ + { + tier: { type: "context" as const, size: 200_000 }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero, + write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero, + }, + }, + ] + : []), + ] +} -const Providers = Schema.Record(Schema.String, Provider) -const decodeProviders = Schema.decodeUnknownEffect(Schema.fromJsonString(Providers)) -const decodeProvidersUnknown = Schema.decodeUnknownEffect(Providers) +function mergeCost(base: ModelV2.Info["cost"], override: SourceModel["cost"] | undefined) { + if (!override) return base + const next = cost(override) + const [baseDefault, ...baseTiers] = base + const [nextDefault, ...nextTiers] = next + const tierKey = (item: ModelV2.Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelV2.Info["cost"][number], right: ModelV2.Info["cost"][number]) => ({ + ...left, + ...right, + tier: right.tier ?? left.tier, + cache: { ...left.cache, ...right.cache }, + }) + const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) + for (const item of nextTiers) { + const current = tiers.get(tierKey(item)) + tiers.set(tierKey(item), current ? merge(current, item) : item) + } + return [ + merge( + baseDefault ?? { + input: Money.USDPerMillionTokens.zero, + output: Money.USDPerMillionTokens.zero, + cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero }, + }, + nextDefault, + ), + ...tiers.values(), + ] +} + +const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] + +function reasoningVariants(provider: SourceProvider, model: SourceModel): NonNullable { + const npm = model.provider?.npm ?? provider.npm + const options = model.reasoning_options ?? [] + const effort = options.find((option) => option.type === "effort") + if (effort?.type === "effort") { + return effort.values.flatMap((value) => { + const raw: unknown = value + const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined + if (id === undefined) return [] + const settings = settingsForEffort(npm, id) + return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] + }) + } + const budget = options.find((option) => option.type === "budget_tokens") + if (budget?.type === "budget_tokens") return budgetVariants(npm, budget) + return [] +} + +function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined { + if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") + return { thinking: { type: "adaptive", display: "summarized" }, effort } + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") + return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + if (npm === "@ai-sdk/azure") return { reasoningEffort: effort } + if (npm === "@ai-sdk/openai") + return { reasoningEffort: effort, reasoningSummary: "auto", include: OPENAI_INCLUDE_ENCRYPTED_REASONING } + if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort } +} + +function budgetVariants( + npm: string | undefined, + option: Extract[number], { type: "budget_tokens" }>, +): NonNullable { + const max = option.max + const high = + option.max === undefined + ? Math.max(option.min ?? 0, 16_000) + : Math.min(Math.max(option.min ?? 0, 16_000), option.max) + return [ + { id: "high", budget: high }, + ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), + ].flatMap((item) => { + const settings = settingsForBudget(npm, item.budget) + return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : [] + }) +} + +function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined { + if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") + return { thinking: { type: "enabled", budgetTokens: budget } } + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") + return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } +} + +function modeName(model: SourceModel, mode: string) { + return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` +} + +function modelInfo( + providerID: ProviderV2.ID, + id: ModelV2.ID, + model: SourceModel, + input: { + readonly name?: string + readonly cost?: ModelV2.Info["cost"] + readonly request?: NonNullable["modes"]>[string]["provider"] + readonly variants?: NonNullable + } = {}, +): ModelV2.Info { + return { + id, + modelID: ModelV2.ID.make(model.id), + providerID, + name: input.name ?? model.name, + family: model.family ? ModelV2.Family.make(model.family) : undefined, + package: model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined, + settings: model.provider?.api ? { baseURL: model.provider.api } : undefined, + capabilities: { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + }, + variants: [...(input.variants ?? [])], + time: { released: released(model.release_date) }, + cost: (input.cost ?? cost(model.cost)).map((item) => ({ + ...item, + tier: item.tier && { ...item.tier }, + cache: { ...item.cache }, + })), + status: model.status ?? "active", + enabled: true, + limit: { context: model.limit.context, input: model.limit.input, output: model.limit.output }, + headers: input.request?.headers ? { ...input.request.headers } : undefined, + body: input.request?.body ? { ...input.request.body } : undefined, + } +} export const Event = ModelsDev.Event -declare const OPENCODE_MODELS_DEV: Record | undefined +declare const OPENCODE_MODELS_DEV: Record | undefined export interface Interface { - readonly get: () => Effect.Effect> + readonly get: () => Effect.Effect readonly refresh: (force?: boolean) => Effect.Effect } @@ -181,7 +335,7 @@ const layer = Layer.effect( }) const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( - Effect.flatMap(decodeProvidersUnknown), + Effect.map((input) => input as Record), Effect.catch((error) => { if ( Flag.OPENCODE_MODELS_PATH === undefined && @@ -196,13 +350,6 @@ const layer = Layer.effect( const loadSnapshot = Effect.sync(() => typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, - ).pipe( - Effect.flatMap((snapshot) => - snapshot === undefined ? Effect.succeed(undefined) : decodeProvidersUnknown(snapshot), - ), - Effect.catch((cause) => - Effect.logWarning("bundled models snapshot failed schema decode", { cause }).pipe(Effect.as(undefined)), - ), ) const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { @@ -222,10 +369,10 @@ const layer = Layer.effect( const populate = Effect.gen(function* () { const fromDisk = yield* loadFromDisk - if (fromDisk) return fromDisk - const snapshot = yield* loadSnapshot - if (snapshot) return snapshot - if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} + if (fromDisk) return normalize(fromDisk) + const bundled = yield* loadSnapshot + if (bundled) return normalize(bundled) + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return [] // Flock is cross-process: concurrent opencode CLIs can race on this cache file. const text = yield* Effect.scoped( Effect.gen(function* () { @@ -233,12 +380,12 @@ const layer = Layer.effect( return yield* fetchAndWrite() }), ) - return yield* decodeProviders(text) + return normalize(JSON.parse(text) as Record) }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) - const get = (): Effect.Effect> => cachedGet + const get = (): Effect.Effect => cachedGet const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { if (!force && (yield* fresh())) return diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index ef59535e81..3d2957e1ba 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,266 +1,41 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import { Money } from "@opencode-ai/schema/money" -import type { ModelInfo } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" -import { ModelV2 } from "../model" import { ModelsDev } from "../models-dev" -import { ProviderV2 } from "../provider" - -function released(date: string) { - const time = Date.parse(date) - return Number.isFinite(time) ? time : 0 -} - -function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] { - const base = { - input: input?.input ?? Money.USDPerMillionTokens.zero, - output: input?.output ?? Money.USDPerMillionTokens.zero, - cache: { - read: input?.cache_read ?? Money.USDPerMillionTokens.zero, - write: input?.cache_write ?? Money.USDPerMillionTokens.zero, - }, - } - return [ - base, - ...(input?.tiers?.map((item) => ({ - tier: item.tier, - input: item.input, - output: item.output, - cache: { - read: item.cache_read ?? Money.USDPerMillionTokens.zero, - write: item.cache_write ?? Money.USDPerMillionTokens.zero, - }, - })) ?? []), - ...(input?.context_over_200k - ? [ - { - tier: { - type: "context" as const, - size: 200_000, - }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, - cache: { - read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero, - write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero, - }, - }, - ] - : []), - ] -} - -function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) { - if (!override) return base - const next = cost(override) - const [baseDefault, ...baseTiers] = base - const [nextDefault, ...nextTiers] = next - const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` - const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({ - ...left, - ...right, - tier: right.tier ?? left.tier, - cache: { ...left.cache, ...right.cache }, - }) - const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) - for (const item of nextTiers) { - const current = tiers.get(tierKey(item)) - tiers.set(tierKey(item), current ? merge(current, item) : item) - } - return [ - merge( - baseDefault ?? { - input: Money.USDPerMillionTokens.zero, - output: Money.USDPerMillionTokens.zero, - cache: { - read: Money.USDPerMillionTokens.zero, - write: Money.USDPerMillionTokens.zero, - }, - }, - nextDefault, - ), - ...tiers.values(), - ] -} - -const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] - -function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { - const npm = model.provider?.npm ?? provider.npm - const options = model.reasoning_options ?? [] - const effort = options.find((option) => option.type === "effort") - if (effort?.type === "effort") { - return effort.values.flatMap((value) => { - const raw: unknown = value - const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined - if (id === undefined) return [] - const settings = settingsForEffort(npm, id) - return settings ? [{ id: ModelV2.VariantID.make(id), settings }] : [] - }) - } - - const budget = options.find((option) => option.type === "budget_tokens") - if (budget?.type === "budget_tokens") return budgetVariants(npm, budget) - - // Toggle-only reasoning is intentionally left for a follow-up because V1 has - // provider/model-specific behavior like MiniMax M3 adaptive thinking and - // Qwen/GLM enable_thinking request shapes in packages/opencode. - return [] -} - -function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined { - if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } } - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { - return { thinking: { type: "adaptive", display: "summarized" }, effort } - } - if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") { - return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } - } - if (npm === "@ai-sdk/azure") return { reasoningEffort: effort } - if (npm === "@ai-sdk/openai") { - return { - reasoningEffort: effort, - reasoningSummary: "auto", - include: OPENAI_INCLUDE_ENCRYPTED_REASONING, - } - } - if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort } -} - -function budgetVariants( - npm: string | undefined, - option: Extract[number], { type: "budget_tokens" }>, -): NonNullable { - const max = option.max - const high = - option.max === undefined - ? Math.max(option.min ?? 0, 16_000) - : Math.min(Math.max(option.min ?? 0, 16_000), option.max) - return [ - { id: "high", budget: high }, - ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), - ].flatMap((item) => { - const settings = settingsForBudget(npm, item.budget) - return settings ? [{ id: ModelV2.VariantID.make(item.id), settings }] : [] - }) -} - -function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined { - if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } } - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { - return { thinking: { type: "enabled", budgetTokens: budget } } - } - if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") { - return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } - } -} - -function modeName(model: ModelsDev.Model, mode: string) { - return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` -} - -function mergeVariants(model: ModelInfo, next: NonNullable) { - const variants = model.variants ?? [] - const existing = new Map(variants.map((variant) => [variant.id, variant])) - const nextIDs = new Set(next.map((variant) => variant.id)) - model.variants = [ - ...next.map((variant) => existing.get(variant.id) ?? variant), - ...variants.filter((variant) => !nextIDs.has(variant.id)), - ] -} - -function applyModel( - draft: ModelInfo, - model: ModelsDev.Model, - input: { - readonly name?: string - readonly cost?: ModelInfo["cost"] - readonly request?: NonNullable["modes"]>[string]["provider"] - readonly variants?: NonNullable - } = {}, -) { - draft.name = input.name ?? model.name - draft.modelID = model.id - draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.package = model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined - draft.settings = model.provider?.api ? { ...draft.settings, baseURL: model.provider.api } : draft.settings - draft.capabilities = { - tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } - mergeVariants(draft, input.variants ?? []) - draft.time.released = released(model.release_date) - draft.cost = (input.cost ?? cost(model.cost)).map((item) => ({ - ...item, - tier: item.tier && { ...item.tier }, - cache: { ...item.cache }, - })) - draft.status = model.status ?? "active" - draft.enabled = true - draft.limit = { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - } - draft.headers = { ...draft.headers, ...input.request?.headers } - draft.body = { ...draft.body, ...input.request?.body } -} export const ModelsDevPlugin = define({ id: "opencode.models-dev", effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service - const loaded = { data: yield* modelsDev.get() } + const loaded = { data: structuredClone(yield* modelsDev.get()) } yield* ctx.integration.transform((integrations) => { - for (const item of Object.values(loaded.data)) { - if (item.env.length === 0) continue - const integrationID = item.id - integrations.update(integrationID, (integration) => (integration.name = item.name)) + for (const provider of loaded.data) { + if (provider.environment.length === 0) continue + const integrationID = provider.info.id + integrations.update(integrationID, (integration) => (integration.name = provider.info.name)) integrations.method.update({ integrationID, method: { type: "key" }, }) integrations.method.update({ integrationID, - method: { type: "env", names: [...item.env] }, + method: { type: "env", names: [...provider.environment] }, }) } }) yield* ctx.catalog.transform((catalog) => { - for (const item of Object.values(loaded.data)) { - const providerID = ProviderV2.ID.make(item.id) - catalog.provider.update(providerID, (provider) => { - provider.name = item.name - provider.package = item.npm ? ProviderV2.aisdk(item.npm) : "" - provider.settings = item.api ? { ...provider.settings, baseURL: item.api } : provider.settings - }) - - for (const model of Object.values(item.models)) { - const baseCost = cost(model.cost) - const variants = reasoningVariants(item, model) - catalog.model.update(providerID, ModelV2.ID.make(model.id), (draft) => - applyModel(draft, model, { cost: baseCost, variants }), - ) - for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { - catalog.model.update(providerID, ModelV2.ID.make(`${model.id}-${mode}`), (draft) => - applyModel(draft, model, { - name: modeName(model, mode), - cost: mergeCost(baseCost, options.cost), - request: options.provider, - variants, - }), - ) - } + for (const provider of loaded.data) { + catalog.provider.update(provider.info.id, (draft) => Object.assign(draft, provider.info)) + for (const model of provider.models) { + catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model)) } } }) yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( Stream.runForEach(() => modelsDev.get().pipe( - Effect.tap((data) => Effect.sync(() => (loaded.data = data))), + Effect.tap((data) => Effect.sync(() => (loaded.data = structuredClone(data)))), Effect.andThen(ctx.integration.reload()), Effect.andThen(ctx.catalog.reload()), ), diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index b45db31b50..fe9e48e1da 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -49,6 +49,7 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const materialized = new Map() const state = State.create({ + name: "reference", initial: () => ({ sources: new Map() }), draft: (draft) => ({ add: (name, source) => draft.sources.set(name, source as Types.DeepMutable), diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 13b93004e4..d325ceb599 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -83,6 +83,7 @@ const layer = Layer.effect( const events = yield* EventV2.Service const state = State.create({ + name: "skill", initial: () => ({ sources: [] }), draft: (draft) => ({ source: (source) => { diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 93dcdb4e5d..88e9984d4a 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -53,6 +53,7 @@ export const inherit = Effect.fnUntraced(function* () { }) export interface Options { + readonly name?: string /** Creates the base value for initial state and every scoped-transform reload. */ readonly initial: () => State /** Wraps mutable state in a domain-specific draft API. */ @@ -89,7 +90,10 @@ export function create(options: Options): Inte const materialize = Effect.fnUntraced(function* () { const next = options.initial() const api = options.draft(next) - for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.reload.update")) + for (const transform of transforms) + yield* apply(transform.run, api).pipe( + Effect.withSpan("State.reload.update", { attributes: { state: options.name ?? "anonymous" } }), + ) yield* commit(next) }) @@ -98,6 +102,7 @@ export function create(options: Options): Inte const result: Interface = { get: () => state, transform: Effect.fn("State.transform")(function* (update) { + yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous") const scope = yield* Scope.Scope return yield* Effect.uninterruptible( Effect.gen(function* () { diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index 81bdfe8c98..8806a9c469 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -38,9 +38,9 @@ export const Info = Schema.Struct({ }) .annotate({ identifier: "Agent.Info" }) .pipe( - statics((schema) => ({ + statics(() => ({ empty: (id: ID) => - schema.make({ + ({ id, name: Name.make(id), request: { settings: {}, headers: {}, body: {} }, @@ -50,7 +50,7 @@ export const Info = Schema.Struct({ { action: "*", resource: "*", effect: "allow" }, { action: "external_directory", resource: "*", effect: "ask" }, ], - }), + }) satisfies Info, })), ) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index c54a22d613..623b80a60a 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -93,9 +93,9 @@ export const Info = Schema.Struct({ }) .annotate({ identifier: "Model.Info" }) .pipe( - statics((schema) => ({ + statics(() => ({ empty: (providerID: Provider.ID, id: ID) => - schema.make({ + ({ id, modelID: id, providerID, @@ -107,6 +107,6 @@ export const Info = Schema.Struct({ status: "active", enabled: true, limit: { context: 0, output: 0 }, - }), + }) satisfies Info, })), ) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index f426a19798..3e98b16990 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -52,7 +52,7 @@ export const Info = Schema.Struct({ }) .annotate({ identifier: "ProviderV2.Info" }) .pipe( - statics((schema) => ({ - empty: (id: ID) => schema.make({ id, name: id, package: "" }), + statics(() => ({ + empty: (id: ID): Info => ({ id, name: id, package: "" }), })), )