Compare commits

...
11 changed files with 156 additions and 13 deletions
@@ -1,4 +1,4 @@
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID, type ReasoningEffort } from "../schema/index.js"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
@@ -19,6 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly reasoningEffort?: ReasoningEffort
}
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
@@ -75,6 +76,8 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
provider: settings.provider,
providerOptions:
settings.reasoningEffort === undefined ? undefined : { openai: { reasoningEffort: settings.reasoningEffort } },
}).model(modelID)
export const baseten = define(profiles.baseten)
+12
View File
@@ -106,6 +106,18 @@ describe("provider package entrypoints", () => {
})
})
test("maps OpenAI-compatible Chat reasoning effort onto the executable model", async () => {
const OpenAICompatible = await import("@opencode-ai/ai/providers/openai-compatible")
const selected = OpenAICompatible.model("custom-model", {
baseURL: "https://chat.example.test/v1",
provider: "example",
reasoningEffort: "high",
})
expect(String(selected.provider)).toBe("example")
expect(selected.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
})
test("maps Anthropic-compatible settings onto the executable model", async () => {
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
const selected = AnthropicCompatible.model("compatible-model", {
@@ -6,6 +6,7 @@ import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
import { LocalReasoning } from "./local-reasoning.js"
const providerID = "lmstudio"
@@ -23,6 +24,10 @@ const RemoteModel = Schema.Struct({
capabilities: Schema.Struct({
vision: Schema.Boolean,
trained_for_tool_use: Schema.Boolean,
reasoning: Schema.Struct({
allowed_options: Schema.Array(Schema.Literals(["off", "on", "low", "medium", "high"])),
default: Schema.Literals(["off", "on", "low", "medium", "high"]),
}).pipe(Schema.optional),
}).pipe(Schema.optional),
})
@@ -70,6 +75,7 @@ export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
output: ["text"],
}
model.variants = LocalReasoning.fromOptions(item.capabilities?.reasoning?.allowed_options ?? [])
model.limit = {
context:
item.loaded_instances.length === 0
@@ -0,0 +1,47 @@
export * as LocalReasoning from "./local-reasoning.js"
import { Model } from "../../model.js"
type Option = "off" | "on" | "low" | "medium" | "high"
export function fromOptions(options: readonly Option[]) {
return variants(
options.map((option) => {
if (option === "off") return ["none", "none"] as const
if (option === "on") return ["thinking", "medium"] as const
return [option, option] as const
}),
)
}
export function infer(engine: "ollama" | "vllm", model: string) {
const id = model.toLowerCase().replaceAll("_", "-")
if (id.includes("gpt-oss") || id.includes("gptoss"))
return variants([
["low", "low"],
["medium", "medium"],
["high", "high"],
])
if (id.includes("deepseek-v4") || id.includes("deepseekv4"))
return variants([
["none", "none"],
["high", "high"],
["max", "max"],
])
if (id.includes("qwen3") || id.includes("gemma-4") || id.includes("gemma4")) return toggle()
return engine === "ollama" ? toggle() : []
}
function toggle() {
return variants([
["none", "none"],
["thinking", "medium"],
])
}
function variants(items: ReadonlyArray<readonly [id: string, effort: string]>) {
return items.map(([id, effort]) => ({
id: Model.VariantID.make(id),
settings: { reasoningEffort: effort },
}))
}
@@ -6,6 +6,7 @@ import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
import { LocalReasoning } from "./local-reasoning.js"
const providerID = "ollama"
@@ -96,6 +97,9 @@ export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input
input: ["text", ...(item.show.capabilities?.includes("vision") ? ["image"] : [])],
output: ["text"],
}
model.variants = item.show.capabilities?.includes("thinking")
? LocalReasoning.infer("ollama", `${item.model} ${model.family ?? ""}`)
: []
model.limit = {
context:
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
@@ -6,6 +6,7 @@ import { Config } from "../../config.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
import { LocalReasoning } from "./local-reasoning.js"
const providerID = "vllm"
@@ -55,6 +56,7 @@ export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input
model.name = item.id
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
model.variants = LocalReasoning.infer("vllm", item.id)
model.limit = { context: item.max_model_len ?? 0, output: 0 }
})
}
+38 -3
View File
@@ -29,10 +29,41 @@ V1 documentation and syntax may be consulted only when the user explicitly
asks about V1 or when needed as migration input. Outputs and recommendations
must still use V2 unless the user specifically requests a V1 result.
## [Configuration](https://opencode.ai/v2/docs/config)
## [CLI](https://opencode.ai/v2/docs/cli)
OpenCode configuration uses JSON or JSONC. Include the published schema so the
user's editor can validate fields and provide autocomplete:
For questions about the terminal interface, command-line invocation, `run`,
`mini`, terminal providers, or other CLI behavior, fetch the
[CLI guide](https://opencode.ai/v2/docs/cli) and the relevant page linked from
that section.
CLI and TUI preferences are separate from OpenCode's server and project
configuration. They live in the global `~/.config/opencode/cli.json`, or
`$XDG_CONFIG_HOME/opencode/cli.json` when `XDG_CONFIG_HOME` is set. There is no
project-local CLI configuration. Most preferences can also be changed from the
TUI by pressing `Ctrl+P` and selecting **Open settings**.
Fetch the full [CLI configuration guide](https://opencode.ai/v2/docs/cli/config)
before editing `cli.json`. It covers terminal-only settings such as themes,
keybindings, terminal plugins, scrolling, attention alerts, diff presentation,
and terminal integration. Do not put these settings in `opencode.json(c)`.
### [Keybinds](https://opencode.ai/v2/docs/cli/keybinds)
Configure keybindings under `keybinds` in `cli.json`. The leader key is the
`keybinds.leader` entry; leader timing is configured separately under
`leader.timeout`. Bindings can use a string, an array of strings, or an object
when event behavior such as `preventDefault` is required. Disable a binding
with `"none"` or `false`.
Never guess a command ID, default binding, or accepted key syntax. Fetch the
full [keybind reference](https://opencode.ai/v2/docs/cli/keybinds), which lists
the current IDs and defaults, before answering or editing a binding.
## [OpenCode configuration](https://opencode.ai/v2/docs/config)
OpenCode's server and project configuration uses JSON or JSONC. Include the
published schema so the user's editor can validate fields and provide
autocomplete:
```jsonc
{
@@ -55,6 +86,10 @@ Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
`references`, `formatter`, and `lsp`.
This configuration is distinct from `cli.json`. Use the
[CLI configuration guide](https://opencode.ai/v2/docs/cli/config) for terminal
preferences, especially themes and keybindings.
Do not guess field names or shapes. Fetch the V2 configuration guide and its
linked topic guide as the source of truth, and preserve unrelated settings when
editing an existing file. Keep the published `$schema` URL in configuration
@@ -60,7 +60,11 @@ describe("LMStudioPlugin", () => {
architecture: "gemma4",
loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }],
max_context_length: 262_144,
capabilities: { vision: true, trained_for_tool_use: true },
capabilities: {
vision: true,
trained_for_tool_use: true,
reasoning: { allowed_options: ["off", "on"], default: "on" },
},
},
{
type: "llm",
@@ -105,6 +109,10 @@ describe("LMStudioPlugin", () => {
name: "Gemma 4 26B A4B",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
limit: { context: 16_384, output: 0 },
variants: [
{ id: "none", settings: { reasoningEffort: "none" } },
{ id: "thinking", settings: { reasoningEffort: "medium" } },
],
})
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
capabilities: { tools: false, input: ["text"], output: ["text"] },
@@ -54,6 +54,7 @@ describe("OllamaPlugin", () => {
return Response.json({
models: [
summary("gemma3:4b", "gemma-digest", "gemma3"),
summary("gpt-oss:20b", "gpt-oss-digest", "gptoss"),
summary("nomic-embed", "embed-digest"),
summary("removed-model", "removed-digest"),
],
@@ -65,10 +66,12 @@ describe("OllamaPlugin", () => {
return Response.json(
body.model === "gemma3:4b"
? {
capabilities: ["completion", "tools", "vision"],
capabilities: ["completion", "tools", "vision", "thinking"],
model_info: { "gemma3.context_length": 131_072 },
}
: show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }),
: body.model === "gpt-oss:20b"
? show({ family: "gptoss", capabilities: ["completion", "thinking"], context: 131_072 })
: show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }),
)
},
}),
@@ -99,6 +102,17 @@ describe("OllamaPlugin", () => {
family: "gemma3",
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
limit: { context: 131_072, output: 0 },
variants: [
{ id: "none", settings: { reasoningEffort: "none" } },
{ id: "thinking", settings: { reasoningEffort: "medium" } },
],
})
expect(yield* catalog.model.get(providerID, Model.ID.make("gpt-oss:20b"))).toMatchObject({
variants: [
{ id: "low", settings: { reasoningEffort: "low" } },
{ id: "medium", settings: { reasoningEffort: "medium" } },
{ id: "high", settings: { reasoningEffort: "high" } },
],
})
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
expect(requests).toContainEqual({ method: "GET", path: "/api/tags" })
@@ -63,7 +63,10 @@ describe("VLLMPlugin", () => {
state.models++
return Response.json({
object: "list",
data: [remoteModel("Qwen/Qwen3-Coder", 65_536), remoteModel("foreign-model", 4096, "other")],
data: [
remoteModel("deepseek-ai/DeepSeek-V4-Flash", 65_536),
remoteModel("foreign-model", 4096, "other"),
],
})
},
}),
@@ -82,7 +85,7 @@ describe("VLLMPlugin", () => {
state.healthy = true
const model = yield* eventually(
catalog.model.get(providerID, Model.ID.make("Qwen/Qwen3-Coder")),
catalog.model.get(providerID, Model.ID.make("deepseek-ai/DeepSeek-V4-Flash")),
(item) => item !== undefined,
)
expect(yield* catalog.provider.get(providerID)).toEqual({
@@ -94,10 +97,15 @@ describe("VLLMPlugin", () => {
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
expect(model).toMatchObject({
modelID: "Qwen/Qwen3-Coder",
name: "Qwen/Qwen3-Coder",
modelID: "deepseek-ai/DeepSeek-V4-Flash",
name: "deepseek-ai/DeepSeek-V4-Flash",
capabilities: { tools: false, input: ["text"], output: ["text"] },
limit: { context: 65_536, output: 0 },
variants: [
{ id: "none", settings: { reasoningEffort: "none" } },
{ id: "high", settings: { reasoningEffort: "high" } },
{ id: "max", settings: { reasoningEffort: "max" } },
],
})
expect(yield* catalog.model.get(providerID, Model.ID.make("foreign-model"))).toBeUndefined()
}),
@@ -165,6 +165,7 @@ OpenCode automatically discovers language models from an Ollama server listening
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from Ollama.
Thinking-capable models also expose reasoning variants.
Embedding-only models are excluded because they cannot drive a session. Disable discovery with
`"plugins": ["-opencode.provider.ollama"]`.
@@ -199,8 +200,9 @@ address, `http://127.0.0.1:1234`. Discovered models use the `lmstudio` provider
}
```
OpenCode refreshes the inventory in the background and reads context, vision, and tool-use capabilities from LM
Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
OpenCode refreshes the inventory in the background and reads context, vision, tool-use, and reasoning capabilities from
LM Studio. Available reasoning controls become model variants. Embedding models are excluded because they cannot drive
a session. Disable discovery with
`"plugins": ["-opencode.provider.lmstudio"]`.
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
@@ -239,6 +241,8 @@ text input and output, but not vision or tools. Tool calling is conservative bec
flags such as `--enable-auto-tool-choice` and `--tool-call-parser`, which model discovery does not report. Disable
discovery with `"plugins": ["-opencode.provider.vllm"]`.
Recognized reasoning models expose reasoning variants.
For a different endpoint or an authenticated server, configure its OpenAI-compatible base URL:
```jsonc title="opencode.jsonc"