mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 21:21:18 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 909bcee42b | |||
| 1272cc2f05 |
@@ -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 * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||||
import type { RouteDefaultsInput } from "../route/client.js"
|
import type { RouteDefaultsInput } from "../route/client.js"
|
||||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||||
@@ -19,6 +19,7 @@ export interface Settings extends ProviderPackage.Settings {
|
|||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly baseURL: string
|
readonly baseURL: string
|
||||||
readonly provider?: string
|
readonly provider?: string
|
||||||
|
readonly reasoningEffort?: ReasoningEffort
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
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 } },
|
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||||
limits: settings.limits,
|
limits: settings.limits,
|
||||||
provider: settings.provider,
|
provider: settings.provider,
|
||||||
|
providerOptions:
|
||||||
|
settings.reasoningEffort === undefined ? undefined : { openai: { reasoningEffort: settings.reasoningEffort } },
|
||||||
}).model(modelID)
|
}).model(modelID)
|
||||||
|
|
||||||
export const baseten = define(profiles.baseten)
|
export const baseten = define(profiles.baseten)
|
||||||
|
|||||||
@@ -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 () => {
|
test("maps Anthropic-compatible settings onto the executable model", async () => {
|
||||||
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
const AnthropicCompatible = await import("@opencode-ai/ai/providers/anthropic-compatible")
|
||||||
const selected = AnthropicCompatible.model("compatible-model", {
|
const selected = AnthropicCompatible.model("compatible-model", {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Config } from "../../config.js"
|
|||||||
import { Model } from "../../model.js"
|
import { Model } from "../../model.js"
|
||||||
import { Provider } from "../../provider.js"
|
import { Provider } from "../../provider.js"
|
||||||
import type { PluginInternal } from "../internal.js"
|
import type { PluginInternal } from "../internal.js"
|
||||||
|
import { LocalReasoning } from "./local-reasoning.js"
|
||||||
|
|
||||||
const providerID = "lmstudio"
|
const providerID = "lmstudio"
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ const RemoteModel = Schema.Struct({
|
|||||||
capabilities: Schema.Struct({
|
capabilities: Schema.Struct({
|
||||||
vision: Schema.Boolean,
|
vision: Schema.Boolean,
|
||||||
trained_for_tool_use: 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),
|
}).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"] : [])],
|
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
|
||||||
output: ["text"],
|
output: ["text"],
|
||||||
}
|
}
|
||||||
|
model.variants = LocalReasoning.fromOptions(item.capabilities?.reasoning?.allowed_options ?? [])
|
||||||
model.limit = {
|
model.limit = {
|
||||||
context:
|
context:
|
||||||
item.loaded_instances.length === 0
|
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 { Model } from "../../model.js"
|
||||||
import { Provider } from "../../provider.js"
|
import { Provider } from "../../provider.js"
|
||||||
import type { PluginInternal } from "../internal.js"
|
import type { PluginInternal } from "../internal.js"
|
||||||
|
import { LocalReasoning } from "./local-reasoning.js"
|
||||||
|
|
||||||
const providerID = "ollama"
|
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"] : [])],
|
input: ["text", ...(item.show.capabilities?.includes("vision") ? ["image"] : [])],
|
||||||
output: ["text"],
|
output: ["text"],
|
||||||
}
|
}
|
||||||
|
model.variants = item.show.capabilities?.includes("thinking")
|
||||||
|
? LocalReasoning.infer("ollama", `${item.model} ${model.family ?? ""}`)
|
||||||
|
: []
|
||||||
model.limit = {
|
model.limit = {
|
||||||
context:
|
context:
|
||||||
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
|
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Config } from "../../config.js"
|
|||||||
import { Model } from "../../model.js"
|
import { Model } from "../../model.js"
|
||||||
import { Provider } from "../../provider.js"
|
import { Provider } from "../../provider.js"
|
||||||
import type { PluginInternal } from "../internal.js"
|
import type { PluginInternal } from "../internal.js"
|
||||||
|
import { LocalReasoning } from "./local-reasoning.js"
|
||||||
|
|
||||||
const providerID = "vllm"
|
const providerID = "vllm"
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input
|
|||||||
model.name = item.id
|
model.name = item.id
|
||||||
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
|
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
|
||||||
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
|
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 }
|
model.limit = { context: item.max_model_len ?? 0, output: 0 }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ describe("LMStudioPlugin", () => {
|
|||||||
architecture: "gemma4",
|
architecture: "gemma4",
|
||||||
loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }],
|
loaded_instances: [{ config: { context_length: 32_768 } }, { config: { context_length: 16_384 } }],
|
||||||
max_context_length: 262_144,
|
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",
|
type: "llm",
|
||||||
@@ -105,6 +109,10 @@ describe("LMStudioPlugin", () => {
|
|||||||
name: "Gemma 4 26B A4B",
|
name: "Gemma 4 26B A4B",
|
||||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||||
limit: { context: 16_384, output: 0 },
|
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({
|
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
|
||||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ describe("OllamaPlugin", () => {
|
|||||||
return Response.json({
|
return Response.json({
|
||||||
models: [
|
models: [
|
||||||
summary("gemma3:4b", "gemma-digest", "gemma3"),
|
summary("gemma3:4b", "gemma-digest", "gemma3"),
|
||||||
|
summary("gpt-oss:20b", "gpt-oss-digest", "gptoss"),
|
||||||
summary("nomic-embed", "embed-digest"),
|
summary("nomic-embed", "embed-digest"),
|
||||||
summary("removed-model", "removed-digest"),
|
summary("removed-model", "removed-digest"),
|
||||||
],
|
],
|
||||||
@@ -65,10 +66,12 @@ describe("OllamaPlugin", () => {
|
|||||||
return Response.json(
|
return Response.json(
|
||||||
body.model === "gemma3:4b"
|
body.model === "gemma3:4b"
|
||||||
? {
|
? {
|
||||||
capabilities: ["completion", "tools", "vision"],
|
capabilities: ["completion", "tools", "vision", "thinking"],
|
||||||
model_info: { "gemma3.context_length": 131_072 },
|
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",
|
family: "gemma3",
|
||||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||||
limit: { context: 131_072, output: 0 },
|
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(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
|
||||||
expect(requests).toContainEqual({ method: "GET", path: "/api/tags" })
|
expect(requests).toContainEqual({ method: "GET", path: "/api/tags" })
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ describe("VLLMPlugin", () => {
|
|||||||
state.models++
|
state.models++
|
||||||
return Response.json({
|
return Response.json({
|
||||||
object: "list",
|
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
|
state.healthy = true
|
||||||
const model = yield* eventually(
|
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,
|
(item) => item !== undefined,
|
||||||
)
|
)
|
||||||
expect(yield* catalog.provider.get(providerID)).toEqual({
|
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((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID)
|
||||||
expect(model).toMatchObject({
|
expect(model).toMatchObject({
|
||||||
modelID: "Qwen/Qwen3-Coder",
|
modelID: "deepseek-ai/DeepSeek-V4-Flash",
|
||||||
name: "Qwen/Qwen3-Coder",
|
name: "deepseek-ai/DeepSeek-V4-Flash",
|
||||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||||
limit: { context: 65_536, output: 0 },
|
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()
|
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.
|
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
|
Embedding-only models are excluded because they cannot drive a session. Disable discovery with
|
||||||
`"plugins": ["-opencode.provider.ollama"]`.
|
`"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
|
OpenCode refreshes the inventory in the background and reads context, vision, tool-use, and reasoning capabilities from
|
||||||
Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
|
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"]`.
|
`"plugins": ["-opencode.provider.lmstudio"]`.
|
||||||
|
|
||||||
For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
|
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
|
flags such as `--enable-auto-tool-choice` and `--tool-call-parser`, which model discovery does not report. Disable
|
||||||
discovery with `"plugins": ["-opencode.provider.vllm"]`.
|
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:
|
For a different endpoint or an authenticated server, configure its OpenAI-compatible base URL:
|
||||||
|
|
||||||
```jsonc title="opencode.jsonc"
|
```jsonc title="opencode.jsonc"
|
||||||
|
|||||||
Reference in New Issue
Block a user