Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/config/plugin/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export const Plugin = PluginV2.define({
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
reasoningOptions: config.capabilities.reasoningOptions?.map((option) =>
option.type === "effort" ? { ...option, values: [...option.values] } : { ...option },
),
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DateTime, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request"
import { ModelsDev } from "./models-dev"

export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
export type ID = typeof ID.Type
Expand All @@ -15,6 +16,7 @@ export type Family = typeof Family.Type

export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
reasoningOptions: Schema.Array(ModelsDev.ResolvedReasoningOption).pipe(Schema.optional),
// mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array),
output: Schema.String.pipe(Schema.Array),
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,72 @@ const Cost = Schema.Struct({
),
})

const reasoningOption = <Fields extends Schema.Struct.Fields>(fields: Fields) =>
Schema.StructWithRest(Schema.Struct(fields), [Schema.Record(Schema.String, Schema.MutableJson)])

export const ReasoningOption = Schema.Union([
reasoningOption({ type: Schema.Literal("toggle") }),
reasoningOption({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
}),
reasoningOption({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
reasoningOption({ type: Schema.String }),
])
export type ReasoningOption = typeof ReasoningOption.Type

export const ResolvedReasoningOption = Schema.Union([
Schema.Struct({ type: Schema.Literal("toggle") }),
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.String),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
])
export type ResolvedReasoningOption =
| { type: "toggle" }
| { type: "effort"; values: string[] }
| { type: "budget_tokens"; min?: number; max?: number }

export function resolveReasoningOptions(
options: readonly ReasoningOption[] | undefined,
): ResolvedReasoningOption[] | undefined {
if (!options) return
return options
.map((option): ResolvedReasoningOption | undefined => {
if (option.type === "toggle") return { type: "toggle" }
if (option.type === "effort" && Array.isArray(option.values)) {
return {
type: "effort",
values: option.values.filter((value): value is string => typeof value === "string"),
}
}
if (option.type !== "budget_tokens") return
return {
type: "budget_tokens",
...(typeof option.min === "number" && Number.isFinite(option.min) ? { min: option.min } : {}),
...(typeof option.max === "number" && Number.isFinite(option.max) ? { max: option.max } : {}),
}
})
.filter((option): option is ResolvedReasoningOption => option !== undefined)
}

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.Boolean,
tool_call: Schema.Boolean,
interleaved: Schema.optional(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/plugin/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const ModelsDevPlugin = PluginV2.define({
}
draft.capabilities = {
tools: model.tool_call,
reasoningOptions: ModelsDev.resolveReasoningOptions(model.reasoning_options),
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
Expand Down
17 changes: 15 additions & 2 deletions packages/core/test/models.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { describe, expect, beforeAll, beforeEach, afterAll, test } from "bun:test"
import { Effect, Layer, Ref, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Flag } from "@opencode-ai/core/flag/flag"
Expand Down Expand Up @@ -125,6 +125,19 @@ const initialState: MockState = {
calls: [],
}

test("reasoning options preserve unknown types and fields", () => {
const options: ModelsDev.ReasoningOption[] = [
{ type: "effort", values: [null, "low", "ultrathink"], default: "low" },
{ type: "future_dynamic_budget", curve: { min: 1, max: 10 }, enabled: true },
]
const model = Schema.decodeUnknownSync(ModelsDev.Model)({
...fixture.acme.models["acme-1"],
reasoning_options: options,
})

expect(model.reasoning_options).toEqual(options)
})

describe("ModelsDev Service", () => {
it.live("get() returns providers from disk when cache file exists", () =>
Effect.gen(function* () {
Expand Down
68 changes: 68 additions & 0 deletions packages/core/test/plugin/models-dev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"

const reasoningOptions: ModelsDev.ReasoningOption[] = [
{ type: "toggle" },
{ type: "effort", values: [null, "low", "ultrathink"], default: "low" },
{ type: "budget_tokens", min: 1024, future: true },
{ type: "future_dynamic_budget", curve: { min: 1, max: 10 }, enabled: true },
]

const modelsDev = Layer.succeed(
ModelsDev.Service,
ModelsDev.Service.of({
get: () =>
Effect.succeed({
acme: {
id: "acme",
name: "Acme",
env: [],
models: {
"acme-1": {
id: "acme-1",
name: "Acme One",
release_date: "2026-01-01",
attachment: false,
reasoning: true,
reasoning_options: reasoningOptions,
temperature: true,
tool_call: true,
limit: { context: 128_000, output: 8_192 },
},
},
},
}),
refresh: () => Effect.void,
}),
)
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const catalog = Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer))
const it = testEffect(Layer.merge(catalog, modelsDev))

describe("ModelsDevPlugin", () => {
it.effect("maps known reasoning options into typed V2 capabilities", () =>
Effect.gen(function* () {
yield* ModelsDevPlugin.effect
const model = yield* (yield* Catalog.Service).model.get(ProviderV2.ID.make("acme"), ModelV2.ID.make("acme-1"))

expect(model.capabilities.reasoningOptions).toEqual([
{ type: "toggle" },
{ type: "effort", values: ["low", "ultrathink"] },
{ type: "budget_tokens", min: 1024 },
])
}),
)
})
3 changes: 3 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,7 @@ const ProviderInterleaved = Schema.Union([
const ProviderCapabilities = Schema.Struct({
temperature: Schema.Boolean,
reasoning: Schema.Boolean,
reasoningOptions: optionalOmitUndefined(Schema.Array(ModelsDev.ResolvedReasoningOption)),
attachment: Schema.Boolean,
toolcall: Schema.Boolean,
input: ProviderModalities,
Expand Down Expand Up @@ -1178,6 +1179,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
capabilities: {
temperature: model.temperature ?? false,
reasoning: model.reasoning ?? false,
reasoningOptions: ModelsDev.resolveReasoningOptions(model.reasoning_options),
attachment: model.attachment ?? false,
toolcall: model.tool_call ?? true,
input: {
Expand Down Expand Up @@ -1400,6 +1402,7 @@ export const layer = Layer.effect(
capabilities: {
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
reasoningOptions: existingModel?.capabilities.reasoningOptions,
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
input: {
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,12 @@ test("mode cost preserves over-200k pricing from base model", () => {
})

test("models.dev normalization fills required response fields", () => {
const reasoningOptions: ModelsDev.ReasoningOption[] = [
{ type: "toggle" },
{ type: "effort", values: [null, "low", "ultrathink"], default: "low" },
{ type: "budget_tokens", min: 1024, future: true },
{ type: "future_dynamic_budget", curve: { min: 1, max: 10 } },
]
const provider = {
id: "gateway",
name: "Gateway",
Expand All @@ -1295,6 +1301,7 @@ test("models.dev normalization fills required response fields", () => {
id: "gpt-5.4",
name: "GPT-5.4",
family: "gpt",
reasoning_options: reasoningOptions,
cost: { input: 2.5, output: 15 },
limit: { context: 1_050_000, input: 922_000, output: 128_000 },
},
Expand All @@ -1305,6 +1312,11 @@ test("models.dev normalization fills required response fields", () => {
expect(model.api.url).toBe("")
expect(model.capabilities.temperature).toBe(false)
expect(model.capabilities.reasoning).toBe(false)
expect(model.capabilities.reasoningOptions).toEqual([
{ type: "toggle" },
{ type: "effort", values: ["low", "ultrathink"] },
{ type: "budget_tokens", min: 1024 },
])
expect(model.capabilities.attachment).toBe(false)
expect(model.capabilities.toolcall).toBe(true)
expect(model.release_date).toBe("")
Expand Down
42 changes: 42 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2082,6 +2082,20 @@ export type Model = {
capabilities: {
temperature: boolean
reasoning: boolean
reasoningOptions?: Array<
| {
type: "toggle"
}
| {
type: "effort"
values: Array<string>
}
| {
type: "budget_tokens"
min?: number
max?: number
}
>
attachment: boolean
toolcall: boolean
input: {
Expand Down Expand Up @@ -2878,6 +2892,20 @@ export type ModelV2Info = {
}
capabilities: {
tools: boolean
reasoningOptions?: Array<
| {
type: "toggle"
}
| {
type: "effort"
values: Array<string>
}
| {
type: "budget_tokens"
min?: number
max?: number
}
>
input: Array<string>
output: Array<string>
}
Expand Down Expand Up @@ -4230,6 +4258,20 @@ export type ModelV2Info1 = {
}
capabilities: {
tools: boolean
reasoningOptions?: Array<
| {
type: "toggle"
}
| {
type: "effort"
values: Array<string>
}
| {
type: "budget_tokens"
min?: number
max?: number
}
>
input: Array<string>
output: Array<string>
}
Expand Down
Loading