Skip to content

Commit 6dcf8f3

Browse files
See USee U
authored andcommitted
feat(opencode): wire v2 MCP tools, deepseek reasoning variants, and run usage display
- mcp/v2: adapt the V1 MCP client to the core V2 MCP interface and reuse the shared catalog call semantics. - provider/transform: share the deepseek toggle+effort variant combination and single-source the official deepseek transport predicate. - cli run: show cache-token usage in summaries. - tests: disable models.dev fetching (fixture-backed) and fix native LLM test isolation.
1 parent 01f0258 commit 6dcf8f3

10 files changed

Lines changed: 553 additions & 34 deletions

File tree

packages/opencode/src/cli/cmd/run/session-data.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,14 @@ function formatUsage(
153153
const text =
154154
limit && limit > 0 ? `${Locale.number(total)} (${Math.round((total / limit) * 100)}%)` : Locale.number(total)
155155

156+
const cache = (tokens?.cache?.read ?? 0) > 0 ? ` · cache ${Locale.number(tokens?.cache?.read ?? 0)}` : ""
157+
const usage = `${text}${cache}`
158+
156159
if (typeof cost === "number" && cost > 0) {
157-
return `${text} · ${money.format(cost)}`
160+
return `${usage} · ${money.format(cost)}`
158161
}
159162

160-
return text
163+
return usage
161164
}
162165

163166
export function formatError(error: {

packages/opencode/src/cli/cmd/run/turn-summary.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@ export function turnSummaryCommit(input: {
66
agent: string
77
model: string
88
duration: string
9+
cachePercent?: number
910
messageID?: string
1011
}): StreamCommit {
12+
const cache = input.cachePercent !== undefined ? ` · cache ${input.cachePercent}%` : ""
1113
return {
1214
kind: "system",
13-
text: `▣ ${input.agent} · ${input.model} · ${input.duration}`,
15+
text: `▣ ${input.agent} · ${input.model} · ${input.duration}${cache}`,
1416
phase: "final",
1517
source: "system",
1618
summary: {
1719
agent: input.agent,
1820
model: input.model,
1921
duration: input.duration,
22+
cachePercent: input.cachePercent,
2023
},
2124
messageID: input.messageID,
2225
}
@@ -36,12 +39,19 @@ export function messageTurnSummaryCommit(
3639
return
3740
}
3841

42+
// V1 token accounting splits prompt tokens: input is non-cached, cache.read
43+
// is the cached share — DeepSeek prices cache hits 50-120x cheaper.
44+
const cacheRead = info.tokens?.cache?.read
45+
const promptTokens = cacheRead !== undefined ? cacheRead + (info.tokens?.cache?.write ?? 0) + (info.tokens?.input ?? 0) : 0
46+
const cachePercent = cacheRead !== undefined && cacheRead > 0 && promptTokens > 0 ? Math.round((cacheRead / promptTokens) * 100) : undefined
47+
3948
const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name
4049

4150
return turnSummaryCommit({
4251
agent: Locale.titlecase(info.agent),
4352
model: model ?? info.modelID,
4453
duration: Locale.duration(completed - info.time.created),
54+
cachePercent,
4555
messageID: info.id,
4656
})
4757
}

packages/opencode/src/cli/cmd/run/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export type TurnSummary = {
101101
agent: string
102102
model: string
103103
duration: string
104+
cachePercent?: number
104105
}
105106

106107
export type ScrollbackOptions = {

packages/opencode/src/mcp/catalog.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,7 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
5151
description: mcpTool.description ?? "",
5252
inputSchema: jsonSchema(inputSchema),
5353
execute: async (args: unknown, options) => {
54-
const result = await client.callTool(
55-
{
56-
name: mcpTool.name,
57-
arguments: (args || {}) as Record<string, unknown>,
58-
},
59-
CallToolResultSchema,
60-
{
61-
resetTimeoutOnProgress: true,
62-
signal: options.abortSignal,
63-
timeout,
64-
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
65-
onprogress: () => {},
66-
},
67-
)
54+
const result = await callTool(client, mcpTool.name, (args || {}) as Record<string, unknown>, timeout, options.abortSignal)
6855
if (result.isError)
6956
throw new Error(
7057
result.content
@@ -82,6 +69,28 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe
8269
})
8370
}
8471

72+
// Shared MCP tool-call invocation used by both the V1 `convertTool` path and the V2 host bridge
73+
// (`@/mcp/v2`). Keeping the call options in one place prevents drift between the two consumers.
74+
export function callTool(
75+
client: Client,
76+
name: string,
77+
args: Record<string, unknown>,
78+
timeout?: number,
79+
signal?: AbortSignal,
80+
) {
81+
return client.callTool(
82+
{ name, arguments: args },
83+
CallToolResultSchema,
84+
{
85+
resetTimeoutOnProgress: true,
86+
signal,
87+
timeout,
88+
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
89+
onprogress: () => {},
90+
},
91+
)
92+
}
93+
8594
export function fetch<T extends { name: string }>(
8695
clientName: string,
8796
client: Client,

packages/opencode/src/mcp/v2.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Effect, Layer } from "effect"
2+
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
3+
import { MCP } from "@opencode-ai/core/tool/mcp"
4+
import { MCP as MCPV1 } from "@/mcp"
5+
import { McpCatalog } from "@/mcp/catalog"
6+
7+
// Adapts the existing V1 MCP client to the core V2 MCP interface so MCP tools register into the V2
8+
// tool registry. Core stays free of the MCP wire protocol; this module is the host-side bridge.
9+
// Note: cancellation (abort signal) is not plumbed through the core `MCP.call` interface yet, so
10+
// `McpCatalog.callTool` is invoked without a signal here.
11+
const layer = Layer.effect(
12+
MCP.Service,
13+
Effect.gen(function* () {
14+
const mcp = yield* MCPV1.Service
15+
return MCP.Service.of({
16+
tools: () =>
17+
Effect.gen(function* () {
18+
const tools = yield* mcp.tools()
19+
return Object.entries(tools).map(([name, entry]) => ({
20+
name,
21+
description: entry.def.description ?? "",
22+
inputSchema: (entry.def.inputSchema ?? {}) as Record<string, unknown>,
23+
}))
24+
}),
25+
call: (name, args) =>
26+
Effect.gen(function* () {
27+
const tools = yield* mcp.tools()
28+
const entry = tools[name]
29+
if (!entry) return yield* Effect.fail(new Error(`MCP tool not found: ${name}`))
30+
const result = yield* Effect.tryPromise({
31+
try: () => McpCatalog.callTool(entry.client, entry.def.name, args, entry.timeout),
32+
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
33+
})
34+
return {
35+
content: (result.content ?? []) as MCP.ToolResultContent[],
36+
isError: result.isError === true,
37+
}
38+
}),
39+
})
40+
}),
41+
)
42+
43+
export const node = makeGlobalNode({ service: MCP.Service, layer, deps: [MCPV1.node] })

packages/opencode/src/provider/transform.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,26 @@ export function topK(model: Provider.Model) {
566566
}
567567

568568
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
569+
// DeepSeek V4 effort tiers on the official API. `low`/`xhigh` are compatibility
570+
// values the server maps (low→high on v4-pro, xhigh→max), so only tiers the
571+
// selected model actually honors are exposed. Flash supports all three; pro
572+
// honors high/max until it gains the full set in early Aug 2026.
573+
// See https://api-docs.deepseek.com/guides/thinking_mode
574+
function deepseekV4Efforts(apiId: string): string[] {
575+
return apiId.toLowerCase().includes("deepseek-v4-flash") ? ["low", "high", "max"] : ["high", "max"]
576+
}
577+
578+
// DeepSeek's own OpenAI-compatible endpoint (api.deepseek.com) is the only
579+
// transport that honors the native `thinking` object and flash's low tier;
580+
// mirrors keep their own toggle surface (e.g. DashScope's enable_thinking).
581+
function isDeepseekV4Official(model: { providerID: string; api: { npm: string; id: string } }) {
582+
return (
583+
model.providerID === "deepseek" &&
584+
model.api.npm === "@ai-sdk/openai-compatible" &&
585+
model.api.id.toLowerCase().includes("deepseek-v4")
586+
)
587+
}
588+
569589
const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
570590
const OPENAI_GPT5_1_EFFORTS = ["none", ...WIDELY_SUPPORTED_EFFORTS]
571591
const OPENAI_GPT5_2_PLUS_EFFORTS = [...OPENAI_GPT5_1_EFFORTS, "xhigh"]
@@ -926,10 +946,14 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
926946
if (model.api.id.toLowerCase().includes("north-mini-code")) {
927947
return Object.fromEntries(["none", "high"].map((effort) => [effort, { reasoningEffort: effort }]))
928948
}
929-
const efforts = [...WIDELY_SUPPORTED_EFFORTS]
930949
if (model.api.id.toLowerCase().includes("deepseek-v4")) {
931-
efforts.push("max")
950+
// Official API exposes the native thinking toggle and (flash) the low
951+
// tier; mirrors share the model's high/max effort surface but not the
952+
// official-only toggle and low tier.
953+
const efforts = isDeepseekV4Official(model) ? deepseekV4Efforts(model.api.id) : ["high", "max"]
954+
return toggleAndEffort(model, effortVariants(model, efforts), isDeepseekV4Official(model))
932955
}
956+
const efforts = [...WIDELY_SUPPORTED_EFFORTS]
933957
return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }]))
934958

935959
case "@ai-sdk/azure":
@@ -1203,6 +1227,15 @@ export function options(input: {
12031227
}
12041228
}
12051229

1230+
// DeepSeek V4 thinking mode defaults to enabled, but send the native toggle
1231+
// explicitly: the docs require an explicit `thinking.type` when combining
1232+
// with `response_format`, and explicit beats implicit if defaults ever change.
1233+
// Variants may override this (none → thinking.type disabled), since they are
1234+
// merged over these options at request time.
1235+
if (isDeepseekV4Official(input.model)) {
1236+
result["thinking"] = { type: "enabled" }
1237+
}
1238+
12061239
if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") {
12071240
result["reasoningSummary"] = "auto"
12081241
result["include"] = INCLUDE_ENCRYPTED_REASONING
@@ -1645,9 +1678,23 @@ export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model
16451678
if (options.length === 0) return {}
16461679

16471680
const effort = options.find((option) => option.type === "effort")
1648-
if (effort) return effortVariants(target, effort.values)
1649-
16501681
const toggle = options.some((option) => option.type === "toggle")
1682+
if (effort) {
1683+
let values = effort.values
1684+
// models.dev lists [high, max] for deepseek-v4-flash, but the official API
1685+
// also honors `low` — expose the full tier set so the fast lane can go faster.
1686+
if (isDeepseekV4Official(target)) {
1687+
values = unique([...deepseekV4Efforts(target.api.id), ...values])
1688+
}
1689+
const variants = effortVariants(target, values)
1690+
// Unsupported effort controls yield no variants (metadata-declared effort
1691+
// is not replaced by heuristic fallback); a declared toggle still applies.
1692+
if (Object.keys(variants).length === 0) return toggle ? nonEmptyVariants(reasoningToggle(target)) : {}
1693+
// mergeDeep (not spread) so toggle layers keep their own keys where effort
1694+
// variants share one — e.g. deepseek-v4 "high" carries thinking.enabled.
1695+
return toggleAndEffort(target, variants, toggle)
1696+
}
1697+
16511698
const budget = options.find((option) => option.type === "budget_tokens")
16521699
if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined
16531700

@@ -1690,17 +1737,33 @@ function nonEmptyVariants(variants: NonNullable<Provider.Model["variants"]>): Pr
16901737
return Object.keys(variants).length > 0 ? variants : undefined
16911738
}
16921739

1740+
// Combine the native thinking toggle with effort variants when both apply. mergeDeep (not spread)
1741+
// keeps the toggle's own keys where an effort variant shares one — e.g. deepseek-v4 "high"
1742+
// carries thinking.enabled.
1743+
function toggleAndEffort(
1744+
model: Provider.Model,
1745+
variants: NonNullable<Provider.Model["variants"]>,
1746+
toggle: boolean,
1747+
): NonNullable<Provider.Model["variants"]> {
1748+
return toggle ? mergeDeep(reasoningToggle(model), variants) : variants
1749+
}
1750+
16931751
function reasoningToggle(model: Provider.Model): NonNullable<Provider.Model["variants"]> {
16941752
if (model.api.npm === "@ai-sdk/alibaba")
16951753
return {
16961754
none: { enableThinking: false },
16971755
high: { enableThinking: true },
16981756
}
1699-
if (model.api.npm === "@ai-sdk/cohere")
1757+
if (model.api.npm === "@ai-sdk/cohere" || isDeepseekV4Official(model)) {
1758+
// DeepSeek V4 toggles thinking via the native `thinking` object on the
1759+
// official API; non-thinking mode is the fast lane for simple tasks.
1760+
// Mirrors serving deepseek-v4 keep their own toggle surface (e.g.
1761+
// DashScope's enable_thinking), so stay official-only.
17001762
return {
17011763
none: { thinking: { type: "disabled" } },
17021764
high: { thinking: { type: "enabled" } },
17031765
}
1766+
}
17041767
return {}
17051768
}
17061769

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
6161
import { Ripgrep } from "@opencode-ai/core/ripgrep"
6262
import { SessionProjector } from "@opencode-ai/core/session/projector"
6363
import { SessionV2 } from "@opencode-ai/core/session"
64+
import { SubagentExecutor } from "@opencode-ai/core/subagent/executor"
6465
import { SessionExecution } from "@opencode-ai/core/session/execution"
6566
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
6667
import { lazy } from "@/util/lazy"
@@ -97,6 +98,8 @@ import { sessionHandlers } from "./handlers/session"
9798
import { tuiHandlers } from "./handlers/tui"
9899
import { handlers } from "@opencode-ai/server/handlers"
99100
import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services"
101+
import { MCP as MCPTool } from "@opencode-ai/core/tool/mcp"
102+
import * as MCPV2 from "@/mcp/v2"
100103
import { layer as locationLayer } from "@opencode-ai/server/location"
101104
import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location"
102105
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
@@ -263,7 +266,7 @@ const app = LayerNode.group([
263266
export function createRoutes(
264267
corsOptions?: CorsOptions,
265268
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
266-
const locationServiceMapV2 = buildLocationServiceMap()
269+
const locationServiceMapV2 = buildLocationServiceMap([[MCPTool.node, MCPV2.node]])
267270

268271
return Layer.mergeAll(
269272
rootApiRoutes,
@@ -288,7 +291,7 @@ export function createRoutes(
288291
Layer.provide(locationLayer),
289292
Layer.provide(PtyEnvironment.layer),
290293
Layer.provide(
291-
AppNodeBuilderV1.build(SessionV2.node, [
294+
AppNodeBuilderV1.build(LayerNode.group([SessionV2.node, SubagentExecutor.node]), [
292295
[LocationServiceMap.node, locationServiceMapV2],
293296
[SessionExecution.node, SessionExecutionLocal.node],
294297
]),

packages/opencode/test/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
3636
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
3737
process.env["XDG_STATE_HOME"] = path.join(dir, "state")
3838
process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
39+
process.env["OPENCODE_DISABLE_MODELS_FETCH"] = "true"
3940
process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true"
4041
process.env["OPENCODE_EXPERIMENTAL_WORKSPACES"] = "true"
4142

0 commit comments

Comments
 (0)