refactor(core): optimize location startup
This commit is contained in:
parent
b642f9c7bc
commit
85fdb3bde6
12 changed files with 322 additions and 384 deletions
|
|
@ -52,6 +52,7 @@ const layer = Layer.effect(
|
|||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "agent",
|
||||
initial: () => ({ agents: new Map() }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.fromIterable(draft.agents.values()) as Info[],
|
||||
|
|
|
|||
|
|
@ -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<Data, Draft>({
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const layer = Layer.effect(
|
|||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "command",
|
||||
initial: () => ({ commands: new Map() }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ const layer = Layer.effect(
|
|||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "integration",
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
|
||||
|
|
|
|||
|
|
@ -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<Cost, "tiers" | "context_over_200k">
|
||||
}
|
||||
|
||||
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<Record<string, string>> }
|
||||
}
|
||||
>
|
||||
>
|
||||
}
|
||||
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<Record<string, SourceModel>>
|
||||
}
|
||||
|
||||
export type Snapshot = {
|
||||
readonly info: ProviderV2.Info
|
||||
readonly models: readonly ModelV2.Info[]
|
||||
readonly environment: readonly string[]
|
||||
}
|
||||
|
||||
function normalize(input: Record<string, SourceProvider>): 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<typeof Model>
|
||||
)
|
||||
}
|
||||
}
|
||||
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<typeof Provider>
|
||||
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<ModelV2.Info["variants"]> {
|
||||
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<NonNullable<SourceModel["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||
): NonNullable<ModelV2.Info["variants"]> {
|
||||
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<NonNullable<SourceModel["experimental"]>["modes"]>[string]["provider"]
|
||||
readonly variants?: NonNullable<ModelV2.Info["variants"]>
|
||||
} = {},
|
||||
): 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<string, Provider> | undefined
|
||||
declare const OPENCODE_MODELS_DEV: Record<string, SourceProvider> | undefined
|
||||
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Record<string, Provider>>
|
||||
readonly get: () => Effect.Effect<readonly Snapshot[]>
|
||||
readonly refresh: (force?: boolean) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
|
|
@ -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<string, SourceProvider>),
|
||||
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<string, SourceProvider>)
|
||||
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
|
||||
|
||||
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
|
||||
|
||||
const get = (): Effect.Effect<Record<string, Provider>> => cachedGet
|
||||
const get = (): Effect.Effect<readonly Snapshot[]> => cachedGet
|
||||
|
||||
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
|
||||
if (!force && (yield* fresh())) return
|
||||
|
|
|
|||
|
|
@ -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<ModelInfo["variants"]> {
|
||||
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<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
|
||||
): NonNullable<ModelInfo["variants"]> {
|
||||
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<ModelInfo["variants"]>) {
|
||||
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<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
|
||||
readonly variants?: NonNullable<ModelInfo["variants"]>
|
||||
} = {},
|
||||
) {
|
||||
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()),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ const layer = Layer.effect(
|
|||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "reference",
|
||||
initial: () => ({ sources: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ const layer = Layer.effect(
|
|||
const events = yield* EventV2.Service
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "skill",
|
||||
initial: () => ({ sources: [] }),
|
||||
draft: (draft) => ({
|
||||
source: (source) => {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const inherit = Effect.fnUntraced(function* () {
|
|||
})
|
||||
|
||||
export interface Options<State, DraftApi> {
|
||||
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<State, DraftApi>(options: Options<State, DraftApi>): 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<State, DraftApi>(options: Options<State, DraftApi>): Inte
|
|||
const result: Interface<State, DraftApi> = {
|
||||
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* () {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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: "" }),
|
||||
})),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue