Skip to content

Commit c8a6698

Browse files
committed
feat: add compaction model override for session compaction operations
Allow users to select a different LLM model for compaction at runtime via TUI, independent of the session model. Enables using cheaper or specialized models for context summarization without changing the primary chat model. Model resolution priority: TUI compaction model override > agent config > session model TUI: app.tsx - register command + slash /compaction-models dialog-model.tsx - target prop; compaction mode shows session model default option context/local.tsx - kv.signal('compaction_model') state prompt/index.tsx - compact model indicator in prompt footer Server: routes/session.ts - summarize route accepts compactionModel message-v2.ts - CompactionPart gains compactionModel field compaction.ts - 3-tier model resolution in process() prompt.ts - passes compactionModel through to process() Config: config.ts - compaction_model_list keybind SDK: types.gen.ts, sdk.gen.ts, openapi.json - regenerated
1 parent 13616e3 commit c8a6698

13 files changed

Lines changed: 174 additions & 16 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,20 @@ function App() {
442442
local.model.cycleFavorite(-1)
443443
},
444444
},
445+
{
446+
title: "Switch compaction model",
447+
value: "compaction_model.list",
448+
keybind: "compaction_model_list",
449+
category: "Agent",
450+
slash: {
451+
name: "compaction-models",
452+
aliases: ["compaction-model"],
453+
},
454+
onSelect: () => {
455+
dialog.replace(() => <DialogModel target="compaction" />)
456+
},
457+
},
458+
445459
{
446460
title: "Switch agent",
447461
value: "agent.list",

packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export function useConnected() {
1515
)
1616
}
1717

18-
export function DialogModel(props: { providerID?: string }) {
18+
export function DialogModel(props: { providerID?: string; target?: "session" | "compaction" }) {
1919
const local = useLocal()
2020
const sync = useSync()
2121
const dialog = useDialog()
@@ -25,14 +25,41 @@ export function DialogModel(props: { providerID?: string }) {
2525
const connected = useConnected()
2626
const providers = createDialogProviderOptions()
2727

28+
const isCompaction = props.target === "compaction"
29+
2830
const showExtra = createMemo(() => connected() && !props.providerID)
2931

32+
function onModelSelect(model: { providerID: string; modelID: string }) {
33+
dialog.clear()
34+
if (isCompaction) {
35+
local.model.compaction.set(model)
36+
return
37+
}
38+
local.model.set(model, { recent: true })
39+
}
40+
3041
const options = createMemo(() => {
3142
const needle = query().trim()
3243
const showSections = showExtra() && needle.length === 0
3344
const favorites = connected() ? local.model.favorite() : []
3445
const recents = local.model.recent()
3546

47+
// "Use session model (default)" option only shown in compaction mode
48+
const defaultOption = isCompaction
49+
? [
50+
{
51+
value: { providerID: "", modelID: "" },
52+
title: "Use session model (default)",
53+
description: "Compaction will use the same model as the session",
54+
category: showSections ? "Default" : undefined,
55+
onSelect: () => {
56+
dialog.clear()
57+
local.model.compaction.clear()
58+
},
59+
},
60+
]
61+
: []
62+
3663
function toOptions(items: typeof favorites, category: string) {
3764
if (!showSections) return []
3865
return items.flatMap((item) => {
@@ -49,10 +76,7 @@ export function DialogModel(props: { providerID?: string }) {
4976
category,
5077
disabled: provider.id === "opencode" && model.id.includes("-nano"),
5178
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
52-
onSelect: () => {
53-
dialog.clear()
54-
local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true })
55-
},
79+
onSelect: () => onModelSelect({ providerID: provider.id, modelID: model.id }),
5680
},
5781
]
5882
})
@@ -87,10 +111,7 @@ export function DialogModel(props: { providerID?: string }) {
87111
category: connected() ? provider.name : undefined,
88112
disabled: provider.id === "opencode" && model.includes("-nano"),
89113
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
90-
onSelect() {
91-
dialog.clear()
92-
local.model.set({ providerID: provider.id, modelID: model }, { recent: true })
93-
},
114+
onSelect: () => onModelSelect({ providerID: provider.id, modelID: model }),
94115
})),
95116
filter((x) => {
96117
if (!showSections) return true
@@ -121,19 +142,22 @@ export function DialogModel(props: { providerID?: string }) {
121142

122143
if (needle) {
123144
return [
145+
...defaultOption,
124146
...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
125147
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
126148
]
127149
}
128150

129-
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
151+
return [...defaultOption, ...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
130152
})
131153

132154
const provider = createMemo(() =>
133155
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
134156
)
135157

136-
const title = createMemo(() => provider()?.name ?? "Select model")
158+
const title = createMemo(() => (isCompaction ? "Select compaction model" : (provider()?.name ?? "Select model")))
159+
160+
const current = createMemo(() => (isCompaction ? local.model.compaction.current() : local.model.current()))
137161

138162
return (
139163
<DialogSelect<ReturnType<typeof options>[number]["value"]>
@@ -151,15 +175,18 @@ export function DialogModel(props: { providerID?: string }) {
151175
title: "Favorite",
152176
disabled: !connected(),
153177
onTrigger: (option) => {
154-
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
178+
const val = option.value as { providerID: string; modelID: string }
179+
if (val.providerID && val.modelID) {
180+
local.model.toggleFavorite(val)
181+
}
155182
},
156183
},
157184
]}
158185
onFilter={setQuery}
159186
flat={true}
160187
skipFilter={true}
161188
title={title()}
162-
current={local.model.current()}
189+
current={current()}
163190
/>
164191
)
165192
}

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,10 @@ export function Prompt(props: PromptProps) {
10121012
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
10131013
</text>
10141014
</Show>
1015+
<Show when={local.model.compaction.current()}>
1016+
<text fg={theme.textMuted}>·</text>
1017+
<text fg={theme.textMuted}>compact: {local.model.compaction.parsed().model}</text>
1018+
</Show>
10151019
</box>
10161020
</Show>
10171021
</box>

packages/opencode/src/cli/cmd/tui/context/local.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ import { useArgs } from "./args"
1313
import { useSDK } from "./sdk"
1414
import { RGBA } from "@opentui/core"
1515
import { Filesystem } from "@/util/filesystem"
16+
import { useKV } from "./kv"
1617

1718
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
1819
name: "Local",
1920
init: () => {
2021
const sync = useSync()
2122
const sdk = useSDK()
2223
const toast = useToast()
24+
const kv = useKV()
2325

2426
function isModelValid(model: { providerID: string; modelID: string }) {
2527
const provider = sync.data.provider.find((x) => x.id === model.providerID)
@@ -320,6 +322,44 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
320322
save()
321323
})
322324
},
325+
compaction: iife(() => {
326+
const key = "compaction_model"
327+
const [get] = kv.signal<{ providerID: string; modelID: string } | undefined>(key, undefined)
328+
return {
329+
current() {
330+
return get() as { providerID: string; modelID: string } | undefined
331+
},
332+
parsed: createMemo(() => {
333+
const value = get() as { providerID: string; modelID: string } | undefined
334+
if (!value) {
335+
return {
336+
provider: undefined,
337+
model: "Using session model",
338+
}
339+
}
340+
const provider = sync.data.provider.find((x) => x.id === value.providerID)
341+
const info = provider?.models[value.modelID]
342+
return {
343+
provider: provider?.name ?? value.providerID,
344+
model: info?.name ?? value.modelID,
345+
}
346+
}),
347+
set(model: { providerID: string; modelID: string }) {
348+
if (!isModelValid(model)) {
349+
toast.show({
350+
message: `Model ${model.providerID}/${model.modelID} is not valid`,
351+
variant: "warning",
352+
duration: 3000,
353+
})
354+
return
355+
}
356+
kv.set(key, { ...model })
357+
},
358+
clear() {
359+
kv.set(key, undefined)
360+
},
361+
}
362+
}),
323363
variant: {
324364
current() {
325365
const m = currentModel()

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,10 +430,12 @@ export function Session() {
430430
})
431431
return
432432
}
433+
const compactionModel = local.model.compaction.current()
433434
sdk.client.session.summarize({
434435
sessionID: route.sessionID,
435436
modelID: selectedModel.modelID,
436437
providerID: selectedModel.providerID,
438+
compactionModel: compactionModel ?? undefined,
437439
})
438440
dialog.clear()
439441
},

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,7 @@ export namespace Config {
828828
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
829829
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
830830
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
831+
compaction_model_list: z.string().optional().default("none").describe("List available compaction models"),
831832
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
832833
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
833834
agent_cycle: z.string().optional().default("tab").describe("Next agent"),

packages/opencode/src/server/routes/session.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,12 @@ export const SessionRoutes = lazy(() =>
511511
providerID: z.string(),
512512
modelID: z.string(),
513513
auto: z.boolean().optional().default(false),
514+
compactionModel: z
515+
.object({
516+
providerID: z.string(),
517+
modelID: z.string(),
518+
})
519+
.optional(),
514520
}),
515521
),
516522
async (c) => {
@@ -535,6 +541,7 @@ export const SessionRoutes = lazy(() =>
535541
modelID: body.modelID,
536542
},
537543
auto: body.auto,
544+
compactionModel: body.compactionModel,
538545
})
539546
await SessionPrompt.loop({ sessionID })
540547
return c.json(true)

packages/opencode/src/session/compaction.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,15 @@ export namespace SessionCompaction {
104104
sessionID: string
105105
abort: AbortSignal
106106
auto: boolean
107+
compactionModel?: { providerID: string; modelID: string }
107108
}) {
108109
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User
109110
const agent = await Agent.get("compaction")
110-
const model = agent.model
111-
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
112-
: await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
111+
const model = input.compactionModel
112+
? await Provider.getModel(input.compactionModel.providerID, input.compactionModel.modelID)
113+
: agent.model
114+
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
115+
: await Provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
113116
const msg = (await Session.updateMessage({
114117
id: Identifier.ascending("message"),
115118
role: "assistant",
@@ -237,6 +240,12 @@ When constructing the summary, try to stick to this template:
237240
modelID: z.string(),
238241
}),
239242
auto: z.boolean(),
243+
compactionModel: z
244+
.object({
245+
providerID: z.string(),
246+
modelID: z.string(),
247+
})
248+
.optional(),
240249
}),
241250
async (input) => {
242251
const msg = await Session.updateMessage({
@@ -255,6 +264,7 @@ When constructing the summary, try to stick to this template:
255264
sessionID: msg.sessionID,
256265
type: "compaction",
257266
auto: input.auto,
267+
compactionModel: input.compactionModel,
258268
})
259269
},
260270
)

packages/opencode/src/session/message-v2.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ export namespace MessageV2 {
196196
export const CompactionPart = PartBase.extend({
197197
type: z.literal("compaction"),
198198
auto: z.boolean(),
199+
compactionModel: z
200+
.object({
201+
providerID: z.string(),
202+
modelID: z.string(),
203+
})
204+
.optional(),
199205
}).meta({
200206
ref: "CompactionPart",
201207
})

packages/opencode/src/session/prompt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@ export namespace SessionPrompt {
533533
abort,
534534
sessionID,
535535
auto: task.auto,
536+
compactionModel: task.compactionModel,
536537
})
537538
if (result === "stop") break
538539
continue

0 commit comments

Comments
 (0)