Compare commits

...

1 Commits

Author SHA1 Message Date
Rhys Sullivan
e73ec6d2d7 feat(model): add provider fast mode toggle 2026-03-16 11:46:41 -07:00
19 changed files with 337 additions and 9 deletions

View File

@@ -1023,6 +1023,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const fast = createMemo(() => local.model.fast.available())
const fastLabel = createMemo(() =>
language.t(local.model.fast.current() ? "command.model.fast.disable" : "command.model.fast.enable"),
)
const accepting = createMemo(() => {
const id = params.id
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
@@ -1534,6 +1538,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/>
</TooltipKeybind>
</div>
<Show when={fast()}>
<Tooltip placement="top" gutter={8} value={fastLabel()}>
<Button
data-action="prompt-fast"
variant="ghost"
onClick={() => local.model.fast.toggle()}
class="h-7 px-2 shrink-0 text-13-medium"
classList={{
"text-text-base": !local.model.fast.current(),
"text-icon-warning-base bg-surface-warning-base": local.model.fast.current(),
}}
style={control()}
aria-label={fastLabel()}
aria-pressed={local.model.fast.current()}
>
{language.t("command.model.fast.label")}
</Button>
</Tooltip>
</Show>
<TooltipKeybind
placement="top"
gutter={8}

View File

@@ -34,6 +34,7 @@ export type FollowupDraft = {
agent: string
model: { providerID: string; modelID: string }
variant?: string
fast?: boolean
}
type FollowupSendInput = {
@@ -88,6 +89,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
agent: input.draft.agent,
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
variant: input.draft.variant,
fast: input.draft.fast,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
@@ -122,6 +124,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
fast: input.draft.fast,
}
const add = () =>
@@ -156,6 +159,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
messageID,
parts: requestParts,
variant: input.draft.variant,
fast: input.draft.fast,
})
return true
} catch (err) {
@@ -297,6 +301,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const currentModel = local.model.current()
const currentAgent = local.agent.current()
const variant = local.model.variant.current()
const fast = local.model.fast.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -398,6 +403,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
agent,
model,
variant,
fast,
}
const clearInput = () => {
@@ -461,6 +467,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
fast,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,

View File

@@ -8,6 +8,7 @@ import { useProviders } from "@/hooks/use-providers"
import { modelEnabled, modelProbe } from "@/testing/model-selection"
import { Persist, persisted } from "@/utils/persist"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import * as Fast from "./model-fast"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
@@ -17,6 +18,7 @@ type State = {
agent?: string
model?: ModelKey
variant?: string | null
fast?: boolean
}
type Saved = {
@@ -79,10 +81,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
current?: string
draft?: State
last?: {
type: "agent" | "model" | "variant"
type: "agent" | "model" | "variant" | "fast"
agent?: string
model?: ModelKey | null
variant?: string | null
fast?: boolean
}
}>({
current: list()[0]?.name,
@@ -191,11 +194,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent: item.name,
model: item.model,
variant: item.variant ?? null,
fast: scope()?.fast,
})
const next = {
agent: item.name,
model: item.model,
variant: item.variant,
fast: scope()?.fast,
} satisfies State
const session = id()
if (session) {
@@ -249,6 +254,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
fast: !!scope()?.fast && Fast.enabled(model),
} satisfies State
}
@@ -296,6 +302,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent: agent.current()?.name,
model: item ?? null,
variant: selected(),
fast: model.fast.current(),
})
write({ model: item })
if (!item) return
@@ -333,6 +340,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: value ?? null,
fast: !!scope()?.fast && Fast.enabled(model),
})
write({ variant: value ?? null })
})
@@ -349,6 +357,34 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
},
},
fast: {
selected() {
return scope()?.fast === true
},
current() {
return this.selected() && this.available()
},
available() {
return Fast.enabled(current())
},
set(value: boolean) {
if (value && !this.available()) return
const model = current()
batch(() => {
setStore("last", {
type: "fast",
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : null,
variant: selected(),
fast: value,
})
write({ fast: value || undefined })
})
},
toggle() {
this.set(!this.current())
},
},
}
const result = {
@@ -372,7 +408,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
handoff.set(handoffKey(dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string; fast?: boolean }) {
const session = id()
if (!session) return
if (msg.sessionID !== session) return
@@ -383,6 +419,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent: msg.agent,
model: msg.model,
variant: msg.variant ?? null,
fast: msg.fast === true,
})
},
},
@@ -405,6 +442,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
: undefined,
variant: result.model.variant.current() ?? null,
fast: result.model.fast.current(),
selected: result.model.variant.selected(),
configured: result.model.variant.configured(),
pick: scope(),

View File

@@ -0,0 +1,28 @@
type Model = {
id: string
provider: {
id: string
}
}
function lower(model: Model) {
return model.id.toLowerCase()
}
export function kind(model: Model | undefined) {
if (!model) return
const id = lower(model)
if (
model.provider.id === "anthropic" &&
(id.includes("claude-opus-4-6") || id.includes("claude-opus-4.6") || id.includes("opus-4-6"))
) {
return "claude"
}
if (model.provider.id === "openai" && id.includes("gpt-5.4")) {
return "codex"
}
}
export function enabled(model: Model | undefined) {
return !!kind(model)
}

View File

@@ -67,6 +67,10 @@ export const dict = {
"command.agent.cycle.description": "Switch to the next agent",
"command.agent.cycle.reverse": "Cycle agent backwards",
"command.agent.cycle.reverse.description": "Switch to the previous agent",
"command.model.fast.label": "Fast",
"command.model.fast.enable": "Enable fast mode",
"command.model.fast.disable": "Disable fast mode",
"command.model.fast.description": "Toggle provider fast mode for supported Claude and Codex models",
"command.model.variant.cycle": "Cycle thinking effort",
"command.model.variant.cycle.description": "Switch to the next effort level",
"command.prompt.mode.shell": "Shell",

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant" | "fast">>) =>
({
id: "msg",
sessionID: "session",
@@ -31,6 +31,24 @@ describe("syncSessionModel", () => {
expect(calls).toEqual([message({ variant: "high" })])
})
test("restores fast mode from the last message", () => {
const calls: unknown[] = []
syncSessionModel(
{
session: {
restore(value) {
calls.push(value)
},
reset() {},
},
},
message({ fast: true }),
)
expect(calls).toEqual([message({ fast: true })])
})
})
describe("resetSessionModel", () => {

View File

@@ -353,6 +353,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />),
}),
modelCommand({
id: "model.fast.toggle",
title: language.t(local.model.fast.current() ? "command.model.fast.disable" : "command.model.fast.enable"),
description: language.t("command.model.fast.description"),
slash: "fast",
disabled: !local.model.fast.available(),
onSelect: () => local.model.fast.toggle(),
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),

View File

@@ -7,20 +7,23 @@ type State = {
agent?: string
model?: ModelKey | null
variant?: string | null
fast?: boolean
}
export type ModelProbeState = {
dir?: string
sessionID?: string
last?: {
type: "agent" | "model" | "variant"
type: "agent" | "model" | "variant" | "fast"
agent?: string
model?: ModelKey | null
variant?: string | null
fast?: boolean
}
agent?: string
model?: (ModelKey & { name?: string }) | undefined
variant?: string | null
fast?: boolean
selected?: string | null
configured?: string
pick?: State

View File

@@ -164,7 +164,8 @@ export function Prompt(props: PromptProps) {
if (msg.agent && isPrimaryAgent) {
local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant)
local.model.variant.set(msg.variant)
local.model.fast.set(msg.fast === true)
}
}
})
@@ -330,6 +331,19 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = Bun.stringWidth(content)
},
},
{
title: local.model.fast.current() ? "Disable fast mode" : "Enable fast mode",
value: "model.fast",
category: "Model",
enabled: local.model.fast.available(),
slash: {
name: "fast",
},
onSelect: (dialog) => {
local.model.fast.toggle()
dialog.clear()
},
},
{
title: "Skills",
value: "prompt.skills",
@@ -586,6 +600,7 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset
const currentMode = store.mode
const variant = local.model.variant.current()
const fast = local.model.fast.current()
if (store.mode === "shell") {
sdk.client.session.shell({
@@ -621,6 +636,7 @@ export function Prompt(props: PromptProps) {
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
variant,
fast,
parts: nonTextParts
.filter((x) => x.type === "file")
.map((x) => ({
@@ -637,6 +653,7 @@ export function Prompt(props: PromptProps) {
agent: local.agent.current().name,
model: selectedModel,
variant,
fast,
parts: [
{
id: PartID.ascending(),
@@ -765,6 +782,8 @@ export function Prompt(props: PromptProps) {
return !!current
})
const showFast = createMemo(() => local.model.fast.current())
const placeholderText = createMemo(() => {
if (props.sessionID) return undefined
if (store.mode === "shell") {
@@ -1028,6 +1047,12 @@ export function Prompt(props: PromptProps) {
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
<Show when={showFast()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.info, bold: true }}>fast</span>
</text>
</Show>
</box>
</Show>
</box>

View File

@@ -13,6 +13,7 @@ import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import * as Fast from "@/provider/fast"
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
@@ -112,12 +113,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
modelID: string
}[]
variant: Record<string, string | undefined>
fast: Record<string, boolean | undefined>
}>({
ready: false,
model: {},
recent: [],
favorite: [],
variant: {},
fast: {},
})
const filePath = path.join(Global.Path.state, "model.json")
@@ -135,6 +138,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
recent: modelStore.recent,
favorite: modelStore.favorite,
variant: modelStore.variant,
fast: modelStore.fast,
})
}
@@ -143,6 +147,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (Array.isArray(x.recent)) setModelStore("recent", x.recent)
if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite)
if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant)
if (typeof x.fast === "object" && x.fast !== null) setModelStore("fast", x.fast)
})
.catch(() => {})
.finally(() => {
@@ -358,6 +363,36 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
this.set(variants[index + 1])
},
},
fast: {
selected() {
const m = currentModel()
if (!m) return false
const key = `${m.providerID}/${m.modelID}`
return modelStore.fast[key] === true
},
current() {
return this.selected() && this.available()
},
available() {
const m = currentModel()
if (!m) return false
const provider = sync.data.provider.find((x) => x.id === m.providerID)
const info = provider?.models[m.modelID]
if (!info) return false
return Fast.enabled(info, { codex: info.providerID === "openai" })
},
set(value: boolean) {
const m = currentModel()
if (!m) return
if (value && !this.available()) return
const key = `${m.providerID}/${m.modelID}`
setModelStore("fast", key, value || undefined)
save()
},
toggle() {
this.set(!this.current())
},
},
}
})

View File

@@ -0,0 +1,45 @@
type Model = {
providerID: string
api: {
id: string
npm: string
}
}
function lower(model: Pick<Model, "api">) {
return model.api.id.toLowerCase()
}
type Input = {
codex?: boolean
}
export function kind(model: Pick<Model, "providerID" | "api">, input?: Input) {
const id = lower(model)
if (
model.providerID === "anthropic" &&
model.api.npm === "@ai-sdk/anthropic" &&
(id.includes("claude-opus-4-6") || id.includes("claude-opus-4.6") || id.includes("opus-4-6"))
) {
return "claude"
}
if (
model.providerID === "openai" &&
input?.codex === true &&
model.api.npm === "@ai-sdk/openai" &&
id.includes("gpt-5.4")
) {
return "codex"
}
}
export function enabled(model: Pick<Model, "providerID" | "api">, input?: Input) {
return !!kind(model, input)
}
export function options(model: Pick<Model, "providerID" | "api">, input?: Input) {
const mode = kind(model, input)
if (mode === "claude") return { speed: "fast" }
if (mode === "codex") return { serviceTier: "priority" }
return {}
}

View File

@@ -6,6 +6,7 @@ import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
import { iife } from "@/util/iife"
import { Flag } from "@/flag/flag"
import * as Fast from "./fast"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
@@ -905,6 +906,10 @@ export namespace ProviderTransform {
return { [key]: options }
}
export function fast(model: Provider.Model, input?: { codex?: boolean }) {
return Fast.options(model, input)
}
export function maxOutputTokens(model: Provider.Model): number {
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
}

View File

@@ -141,6 +141,7 @@ export namespace SessionCompaction {
mode: "compaction",
agent: "compaction",
variant: userMessage.variant,
fast: userMessage.fast,
summary: true,
path: {
cwd: Instance.directory,

View File

@@ -101,11 +101,15 @@ export namespace LLM {
sessionID: input.sessionID,
providerOptions: provider.options,
})
const fast = (
input.small || !input.user.fast ? {} : ProviderTransform.fast(input.model, { codex: isCodex })
) as Record<string, any>
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
base as Record<string, any>,
mergeDeep(input.model.options as Record<string, any>),
mergeDeep(input.agent.options as Record<string, any>),
mergeDeep(variant as Record<string, any>),
mergeDeep(fast),
)
if (isCodex) {
options.instructions = SystemPrompt.instructions()

View File

@@ -369,6 +369,7 @@ export namespace MessageV2 {
system: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
variant: z.string().optional(),
fast: z.boolean().optional(),
}).meta({
ref: "UserMessage",
})
@@ -437,6 +438,7 @@ export namespace MessageV2 {
}),
structured: z.any().optional(),
variant: z.string().optional(),
fast: z.boolean().optional(),
finish: z.string().optional(),
}).meta({
ref: "AssistantMessage",

View File

@@ -111,6 +111,7 @@ export namespace SessionPrompt {
format: MessageV2.Format.optional(),
system: z.string().optional(),
variant: z.string().optional(),
fast: z.boolean().optional(),
parts: z.array(
z.discriminatedUnion("type", [
MessageV2.TextPart.omit({
@@ -363,6 +364,7 @@ export namespace SessionPrompt {
mode: task.agent,
agent: task.agent,
variant: lastUser.variant,
fast: lastUser.fast,
path: {
cwd: Instance.directory,
root: Instance.worktree,
@@ -575,6 +577,7 @@ export namespace SessionPrompt {
mode: agent.name,
agent: agent.name,
variant: lastUser.variant,
fast: lastUser.fast,
path: {
cwd: Instance.directory,
root: Instance.worktree,
@@ -984,6 +987,7 @@ export namespace SessionPrompt {
system: input.system,
format: input.format,
variant,
fast: input.fast,
}
using _ = defer(() => InstructionPrompt.clear(info.id))
@@ -1310,6 +1314,7 @@ export namespace SessionPrompt {
model: input.model,
messageID: input.messageID,
variant: input.variant,
fast: input.fast,
},
{
message: info,
@@ -1727,6 +1732,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
arguments: z.string(),
command: z.string(),
variant: z.string().optional(),
fast: z.boolean().optional(),
parts: z
.array(
z.discriminatedUnion("type", [
@@ -1884,6 +1890,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
agent: userAgent,
parts,
variant: input.variant,
fast: input.fast,
})) as MessageV2.WithParts
Bus.publish(Command.Event.Executed, {

View File

@@ -176,6 +176,70 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
})
})
describe("ProviderTransform.fast", () => {
const createModel = (input: { providerID: string; modelID: string; npm: string }) =>
({
id: input.modelID,
providerID: input.providerID,
api: {
id: input.modelID,
url: "https://example.com",
npm: input.npm,
},
name: input.modelID,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 200000, output: 8192 },
status: "active",
options: {},
headers: {},
}) as any
test("uses speed fast for anthropic opus 4.6", () => {
const model = createModel({
providerID: "anthropic",
modelID: "claude-opus-4-6",
npm: "@ai-sdk/anthropic",
})
expect(ProviderTransform.fast(model)).toEqual({ speed: "fast" })
})
test("uses priority service tier for openai gpt-5 codex models", () => {
const model = createModel({
providerID: "openai",
modelID: "gpt-5.4",
npm: "@ai-sdk/openai",
})
expect(ProviderTransform.fast(model, { codex: true })).toEqual({ serviceTier: "priority" })
})
test("returns empty options for unsupported models", () => {
const model = createModel({
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
npm: "@ai-sdk/anthropic",
})
expect(ProviderTransform.fast(model)).toEqual({})
})
test("returns empty options for openai api mode", () => {
const model = createModel({
providerID: "openai",
modelID: "gpt-5.4",
npm: "@ai-sdk/openai",
})
expect(ProviderTransform.fast(model)).toEqual({})
})
})
describe("ProviderTransform.options - gateway", () => {
const sessionID = "test-session-123"

View File

@@ -1841,6 +1841,7 @@ export class Session2 extends HeyApiClient {
format?: OutputFormat
system?: string
variant?: string
fast?: boolean
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
},
options?: Options<never, ThrowOnError>,
@@ -1861,6 +1862,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "format" },
{ in: "body", key: "system" },
{ in: "body", key: "variant" },
{ in: "body", key: "fast" },
{ in: "body", key: "parts" },
],
},
@@ -1973,6 +1975,7 @@ export class Session2 extends HeyApiClient {
format?: OutputFormat
system?: string
variant?: string
fast?: boolean
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
},
options?: Options<never, ThrowOnError>,
@@ -1993,6 +1996,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "format" },
{ in: "body", key: "system" },
{ in: "body", key: "variant" },
{ in: "body", key: "fast" },
{ in: "body", key: "parts" },
],
},
@@ -2026,6 +2030,7 @@ export class Session2 extends HeyApiClient {
arguments?: string
command?: string
variant?: string
fast?: boolean
parts?: Array<{
id?: string
type: "file"
@@ -2051,6 +2056,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "arguments" },
{ in: "body", key: "command" },
{ in: "body", key: "variant" },
{ in: "body", key: "fast" },
{ in: "body", key: "parts" },
],
},

View File

@@ -238,6 +238,7 @@ export type UserMessage = {
[key: string]: boolean
}
variant?: string
fast?: boolean
}
export type ProviderAuthError = {
@@ -340,6 +341,7 @@ export type AssistantMessage = {
}
structured?: unknown
variant?: string
fast?: boolean
finish?: string
}
@@ -3284,6 +3286,7 @@ export type SessionPromptData = {
format?: OutputFormat
system?: string
variant?: string
fast?: boolean
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
}
path: {
@@ -3484,6 +3487,7 @@ export type SessionPromptAsyncData = {
format?: OutputFormat
system?: string
variant?: string
fast?: boolean
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
}
path: {
@@ -3526,6 +3530,7 @@ export type SessionCommandData = {
arguments: string
command: string
variant?: string
fast?: boolean
parts?: Array<{
id?: string
type: "file"