feat(model-modes): declare optional model modes and offer OpenAI Pro on GPT-5.6/GPT-6

A model can now declare optional run modes beside its effort ladder, in the
same model capability table. Each declaration names its id, the frontend's
label/hint keys, how it maps onto the request, the wires (kinds) and the
vendor endpoint it was established on, and whether it costs noticeably more.

GPT-5.6 (gpt-5.6 alias, sol/terra/luna) and GPT-6 (sol/luna/astra) declare
"pro", sent as Responses reasoning.mode "pro" beside reasoning.effort, on the
Responses API at api.openai.com only (developers.openai.com/api/docs/guides/
reasoning: mode and effort are independent; pro does more model work and is
billed at the model's rates for more tokens).

The mode is session state, not config and not prompt text:
- control.Controller lists the modes the session's model declares and sets
  one; an undeclared mode is refused with ErrModelModeUnsupported.
- The agent carries the chosen id on every main-loop provider.Request; a new
  conversation starts with it off. Subagents, compaction and side models
  never carry it.
- The Responses adapter sends reasoning.mode only for a mode its endpoint
  declared (projected by boot into the provider config), so a relay, Chat
  Completions or a model without the declaration never sees the field.
- A rebuild carries the mode onto a model that declares it and drops it
  otherwise, so a model switch turns Pro off.
- serve: POST /model-mode, refused as model_mode.unsupported; /status lists
  modes only for a model that declares any.

Studio shows a switch at the bottom of the effort menu only when the model
declares a mode, marked as costing more; the trigger reads "... · Pro mode"
while it is on. The row stays pinned to the bottom of a scrolling menu.
This commit is contained in:
esengine committed 2026-09-28 09:39:17 -07:00
1 parent 025bd17b2f
commit c0f4d5ea15
38 files changed
+863 -31

No files matched your search

@@ -6,6 +6,10 @@ export const EN_COMPOSER: Record<string, string> = {
"超深入": "Extra deep",
"不做推理直接作答,响应最快,适合简单问答": "Answers without reasoning; fastest, for simple questions",
"比深入投入更多推理,适合困难的多步问题": "More reasoning than Deep, for hard multi-step problems",
"Pro 模式": "Pro mode",
"更慢:模型为同一请求做更多推理,消耗的 token 与费用明显更高。仅对当前会话生效。":
"Slower: the model does more reasoning for the same request, using noticeably more tokens and money. Applies to this session only.",
"费用更高": "Costs more",
"停下": "Stop",
"发送": "Send",
"插话": "Steer",
@@ -114,6 +114,7 @@ export const EN_KERNEL: Record<string, string> = {
"{provider} names no reasoning-effort levels. To give it some, set reasoning_protocol or supported_efforts in its config block",
"{provider} 不提供「{level}」档位,可选值为:{levels}": "{provider} has no \"{level}\" level. Available: {levels}",
"无法识别当前使用的来源,请先切换一次模型": "Cannot tell which source is in use — switch models once and try again",
"当前模型 {model} 不支持「{mode}」模式": "The current model {model} does not support the “{mode}” mode",
"任务正在运行,请先停止再切换工作区": "A task is running — stop it before changing the workspace",
"任务正在运行,请先停止再重载扩展": "A task is running — stop it before reloading extensions",
"该来源正在使用中,请先切换模型再删除": "This source is in use — switch models before deleting it",
+1
View File
@@ -170,6 +170,7 @@ const SAID: Record<string, string> = {
"{provider} 没说自己有哪些推理强度档位。要有,得在它的配置块里写 reasoning_protocol 或 supported_efforts",
"effort.unsupported_level": "{provider} 不提供「{level}」档位,可选值为:{levels}",
"effort.no_provider": "无法识别当前使用的来源,请先切换一次模型",
"model_mode.unsupported": "当前模型 {model} 不支持「{mode}」模式",
"prompt_refine.empty": "输入框是空的,先写点内容再优化",
"prompt_refine.too_long": "内容超过 {max_bytes} 字节,无法优化",
+1
View File
@@ -780,6 +780,7 @@ export class MockPort extends MockBackup implements AgentPort {
async setEffort(effort: string) {
this.state.effort = effort;
}
async setModelMode(mode: string) { this.state.modes = this.state.modes?.map((m) => ({ ...m, active: m.id === mode })); }
async setGoal(text: string) {
this.state.goal = text;
}
+11
View File
@@ -61,6 +61,17 @@ export interface ModelEntry {
preset?: boolean;
}
// An optional way the session's model can run, beside its effort level. The
// kernel lists one only for a model that declares it; label and hint are keys
// the frontend owns the wording for.
export interface ModelMode {
id: string;
labelKey: string;
hintKey: string;
costlier: boolean;
active: boolean;
}
// A configured provider as the settings panel lists it.
// Which model takes which job. Only the roles with a field behind them in the
// kernel appear here; image routing joins once it can name its own model
+4 -2
View File
@@ -14,7 +14,7 @@ import type { MemoryCatalog, MemoryEdit, MemoryEntry } from "./memory";
import type { UsageReport } from "./usage";
export type { MemoryEdit } from "./memory";
export type { Money, UsageDay, UsageModel, UsageProvider, UsageReport } from "./usage";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelPrice, RoleAssignments } from "./model";
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments } from "./model";
import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalMode, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
@@ -29,7 +29,7 @@ export type { AccountState, AccountUser, ApprovalMode, ApprovalVerdict, Capabili
Checkpoint, CompactionSettings, Completion, CompletionItem, ContextBreakdown, DeviceGrant, HistoryMessage, HostTodo, BrowserTab,
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
MemoryEntry, ModelEntry, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindScope, RoleAssignments, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
VersionHub, WalletLine, WalletReading, ChangeDiff, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
@@ -521,6 +521,8 @@ export interface AgentPort {
setPreset(preset: Preset): Promise<void>;
setModel(ref: string): Promise<void>;
setEffort(effort: string): Promise<void>;
// "" turns the session's model mode off.
setModelMode(mode: string): Promise<void>;
compaction(): Promise<CompactionSettings>;
saveCompaction(softLimitTokens: number): Promise<CompactionSettings>;
setGoal(text: string): Promise<void>;
@@ -1,3 +1,4 @@
import type { ModelMode } from "./model";
import type { Via } from "./wire";
// A conversation and the states it passes through: what is running, what it
// cost, and the points it can be wound back to.
@@ -210,6 +211,8 @@ export interface SessionStatus {
decisions?: Decision[];
preset: Preset;
effort?: string;
// Absent unless the session's model declares a mode: its switch is not drawn.
modes?: ModelMode[];
modelRef?: string;
toolApprovalMode: ApprovalMode;
autoApproveTools: boolean;
+3
View File
@@ -741,6 +741,9 @@ export class SsePort extends SseBackup implements AgentPort {
setEffort(effort: string) {
return this.post("/effort", { effort });
}
setModelMode(mode: string) {
return this.post("/model-mode", { mode });
}
compaction() {
return this.get<CompactionSettings>("/compaction");
}
@@ -4007,3 +4007,28 @@ body { font-size: 14px; line-height: 1.75; }
@media (max-width: 560px) {
.remote-closed { right: 12px; bottom: 12px; width: calc(100vw - 24px); }
}
.portmenu .studio-item-switch {
position: relative;
justify-self: end;
width: 28px;
height: 16px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--overlay);
}
.portmenu .studio-item-switch::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--ghost);
transition: transform var(--m-line), background var(--m-line);
}
.portmenu .mi[aria-checked="true"] .studio-item-switch { border-color: var(--accent); background: var(--accent-wash); }
.portmenu .mi[aria-checked="true"] .studio-item-switch::after { background: var(--accent); transform: translateX(12px); }
.studio-effort-menu .mi[aria-checked] .studio-item-badge { background: var(--overlay); color: var(--warn-ink); }
.studio-effort-menu .mi[aria-checked] { position: sticky; bottom: 0; z-index: 1; background: var(--raised); box-shadow: 0 -1px 0 var(--border); }
.studio-effort-menu .mi[aria-checked]:not(:disabled):hover { background: var(--overlay); }
@@ -408,3 +408,43 @@ describe("the effort ladder follows the source", () => {
expect(models).toHaveBeenCalledTimes(2);
});
});
describe("a model mode switch", () => {
const gpt: ModelEntry[] = [{ ref: "openai/gpt-5.6-sol", provider: "openai", model: "gpt-5.6-sol", efforts: ["auto", "medium", "high"] }];
const pro = (active: boolean) => [{ id: "pro", labelKey: "model_mode.pro", hintKey: "model_mode.pro.hint", costlier: true, active }];
const open = async (st: SessionStatus, port = new MockPort()) => {
vi.spyOn(port, "models").mockResolvedValue(gpt);
const view = draw({ port, st });
await waitFor(() => expect(view.container.querySelector(".studio-effort-picker")?.textContent).not.toContain("未声明"));
fireEvent.click(view.container.querySelector(".studio-effort-picker") as HTMLElement);
return { ...view, port };
};
it("is drawn only for a model that declares a mode", async () => {
await open(status({ modelRef: "openai/gpt-5.6-sol" }));
expect(screen.queryByRole("menuitemcheckbox")).toBeNull();
});
it("says it costs more and turns the mode on", async () => {
const port = new MockPort();
const set = vi.spyOn(port, "setModelMode");
await open(status({ modelRef: "openai/gpt-5.6-sol", modes: pro(false) }), port);
const row = screen.getByRole("menuitemcheckbox");
expect(row.getAttribute("aria-checked")).toBe("false");
expect(row.textContent).toContain("Pro 模式");
expect(row.textContent).toContain("费用更高");
fireEvent.click(row);
await waitFor(() => expect(set).toHaveBeenCalledWith("pro"));
});
it("reads as on, and a second pick turns it off", async () => {
const port = new MockPort();
const set = vi.spyOn(port, "setModelMode");
const { container } = await open(status({ modelRef: "openai/gpt-5.6-sol", modes: pro(true) }), port);
expect(container.querySelector(".studio-effort-picker")?.textContent).toContain("Pro 模式");
const row = screen.getByRole("menuitemcheckbox");
expect(row.getAttribute("aria-checked")).toBe("true");
fireEvent.click(row);
await waitFor(() => expect(set).toHaveBeenCalledWith(""));
});
});
+12 -11
View File
@@ -5,7 +5,7 @@ import type { AgentPort, ChipCall, ModelEntry, SessionStatus, Attachment } from
import { Picker } from "./Menu";
import { Policy } from "./Policy";
import { modelMenu } from "./modelmenu";
import { effortMenu, effortReading, effortsFor } from "./effort";
import { effortMenu, effortReading, effortsFor, routeEffortPick } from "./effort";
import { CompletionMenu, useCompletion } from "./Completion";
import { ChipMirror, useSkillChips } from "./ChipMirror";
import { useIme } from "./ime";
@@ -384,12 +384,9 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
// composer answering for an endpoint that never spoke.
const declared = efforts.length > 0;
const modelLb = status?.modelRef?.replace(/^[^/]+\//, "") ?? status?.label ?? "—";
// A model switch rebuilds the runtime kernel-side (~0.4s on a real session);
// the other controls on this shelf may land immediately, and which is which
// is the kernel's to say, not this file's. Either way the click needs a
// pending state or it reads as a dead control —— 而等的是哪一个就只标哪一
// 个:一整排一起变灰,说的是「现在什么都不能改」,而实际上只有这一个在等
// 内核回话。
// A model switch rebuilds the runtime kernel-side; other controls here may
// land at once. Each click needs its own pending state: greying the whole
// shelf says nothing can change while only one control waits on the kernel.
const busyRef = useRef(new Set<string>());
const change = (key: string, call: () => Promise<void>) => {
if (busyRef.current.has(key)) return;
@@ -735,10 +732,14 @@ export function Composer({ port, status, running, quote, focus, onSubmit, onChan
onOpen={loadModels}
title={t("推理强度")}
current={declared ? status.effort || "auto" : ""}
pending={busy["effort"]}
items={effortMenu(efforts, modelLb, "__effort-declare")}
onPick={(value) => value === "__effort-declare" ? onSettings("providers:effort-declare") : change("effort", () => port.setEffort(value))}
label={<><span>{declared ? effortReading(status.effort) : t("未声明")}</span><StudioIcon name="down" /></>}
pending={busy["effort"] || busy["mode"]}
items={effortMenu(efforts, modelLb, "__effort-declare", status.modes)}
onPick={(value) => routeEffortPick(value, status.modes, {
declare: () => onSettings("providers:effort-declare"),
effort: (level) => change("effort", () => port.setEffort(level)),
mode: (mode) => change("mode", () => port.setModelMode(mode)),
})}
label={<><span>{declared ? effortReading(status.effort, status.modes) : t("未声明")}</span><StudioIcon name="down" /></>}
/>
</div>
)}
+6 -1
View File
@@ -22,6 +22,9 @@ export interface MenuItem {
// through twice.
header?: boolean;
disabled?: boolean;
// A switch rather than a choice: picking it flips this state, and the row
// says which state it is in instead of being marked as the current value.
toggle?: boolean;
}
// Where a list stops fitting the menu's own height cap. Past it a scrollbar
@@ -232,7 +235,8 @@ export function Picker({
{...id}
data-value={it.value}
className={it.plain ? "mi plain" : "mi"}
role="menuitem"
role={it.toggle === undefined ? "menuitem" : "menuitemcheckbox"}
aria-checked={it.toggle}
disabled={it.disabled}
data-on={it.value === current ? "" : undefined}
data-lead={it.value === lead ? "" : undefined}
@@ -254,6 +258,7 @@ export function Picker({
{[1, 2, 3, 4].map((level) => <i key={level} data-on={level <= (it.strength ?? 0) ? "" : undefined} />)}
</span>
)}
{it.toggle !== undefined && <span className="studio-item-switch" aria-hidden="true" />}
{it.right && <span className="rt">{it.right}</span>}
</button>
)}
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { effortMenu } from "./effort";
import { effortMenu, routeEffortPick } from "./effort";
describe("a ladder that carries every OpenAI rung", () => {
const ladder = ["auto", "none", "low", "medium", "high", "xhigh", "max"];
@@ -15,3 +15,29 @@ describe("a ladder that carries every OpenAI rung", () => {
expect(none && "strength" in none ? none.strength : undefined).toBe(0);
});
});
describe("a mode row in the effort menu", () => {
const pro = { id: "pro", labelKey: "model_mode.pro", hintKey: "model_mode.pro.hint", costlier: true, active: false };
it("sits after the ladder and before the footnote, as a switch", () => {
const rows = effortMenu(["auto", "high"], "gpt-5.6-sol", "__declare", [pro]);
const at = rows.findIndex((row) => row.value === "__mode:pro");
expect(at).toBe(rows.length - 2);
expect(rows[at]).toMatchObject({ toggle: false, divide: true });
});
it("is absent when the model declares none", () => {
expect(effortMenu(["auto", "high"], "qwen3.8-max", "__declare").some((row) => row.value.startsWith("__mode:"))).toBe(false);
});
it("routes a pick to the mode, the declaration or the level it names", () => {
const calls: string[] = [];
const act = { declare: () => calls.push("declare"), effort: (l: string) => calls.push(`effort:${l}`), mode: (m: string) => calls.push(`mode:${m}`) };
routeEffortPick("__mode:pro", [pro], act);
routeEffortPick("__mode:pro", [{ ...pro, active: true }], act);
routeEffortPick("__mode:gone", [pro], act);
routeEffortPick("__effort-declare", [pro], act);
routeEffortPick("high", [pro], act);
expect(calls).toEqual(["mode:pro", "mode:", "declare", "effort:high"]);
});
});
+39 -4
View File
@@ -1,5 +1,5 @@
import { t } from "../i18n";
import type { ModelEntry } from "../port/port";
import type { ModelEntry, ModelMode } from "../port/port";
// Only the ladder the kernel would accept for the model in hand. A fixed list
// here offered rungs a given model does not have, and picking one looked like
@@ -25,11 +25,38 @@ const EFFORT_LABELS: Record<string, string> = {
auto: "自动", disabled: "关闭", none: "不思考", low: "快速", medium: "平衡", high: "深入", xhigh: "超深入", max: "极致",
};
export function effortReading(value?: string): string {
export function effortReading(value?: string, modes?: ModelMode[]): string {
const id = (value || "auto").toLowerCase();
const label = EFFORT_LABELS[id] ?? id;
const raw = id === "auto" ? "Auto" : id.charAt(0).toUpperCase() + id.slice(1);
return `${t(label)} · ${raw}`;
const on = modes?.find((m) => m.active);
return `${t(label)} · ${raw}${on ? ` · ${modeLabel(on)}` : ""}`;
}
// The kernel names a mode's strings by key; the wording is ours. A key this
// build has no words for shows the mode's id rather than nothing.
const MODE_TEXT: Record<string, string> = {
"model_mode.pro": "Pro 模式",
"model_mode.pro.hint": "更慢:模型为同一请求做更多推理,消耗的 token 与费用明显更高。仅对当前会话生效。",
};
export function modeLabel(mode: ModelMode): string {
return MODE_TEXT[mode.labelKey] ? t(MODE_TEXT[mode.labelKey]) : mode.id;
}
const MODE_ROW = "__mode:";
/** What a row of the effort menu asks for. A mode row flips that mode, so the
* answer is the mode to set — "" when it is the one already on. */
export function routeEffortPick(
value: string,
modes: ModelMode[] | undefined,
act: { declare: () => void; effort: (level: string) => void; mode: (mode: string) => void },
) {
const mode = value.startsWith(MODE_ROW) ? modes?.find((m) => m.id === value.slice(MODE_ROW.length)) : undefined;
if (mode) act.mode(mode.active ? "" : mode.id);
else if (value === "__effort-declare") act.declare();
else if (!value.startsWith(MODE_ROW)) act.effort(value);
}
export function effortLabel(value: string): string {
@@ -59,7 +86,7 @@ export function effortDescription(value: string): string {
/** The rows the effort picker offers. An endpoint that reported no levels has
* not said "none" — it has said nothing, so rather than invent a rung the one
* row here points at where the capability is declared. */
export function effortMenu(efforts: string[], modelLabel: string, onDeclare: string) {
export function effortMenu(efforts: string[], modelLabel: string, onDeclare: string, modes: ModelMode[] = []) {
const declared = efforts.length > 0;
return [
{ value: "__effort-heading", label: t("推理强度"), right: modelLabel, header: true },
@@ -73,6 +100,14 @@ export function effortMenu(efforts: string[], modelLabel: string, onDeclare: str
strength: value === "disabled" || value === "none" ? 0 : ({ auto: 1, low: 1, medium: 2, high: 3, xhigh: 4, max: 4 } as Record<string, number>)[value.toLowerCase()] ?? 1,
desc: effortDescription(value),
})),
...modes.map((mode, i) => ({
value: MODE_ROW + mode.id,
label: modeLabel(mode),
desc: MODE_TEXT[mode.hintKey] ? t(MODE_TEXT[mode.hintKey]) : undefined,
badge: mode.costlier ? t("费用更高") : undefined,
toggle: mode.active,
divide: i === 0,
})),
...(declared ? [{ value: "__effort-note", label: t("仅显示当前模型实际支持的档位。"), right: t("按模型生效"), header: true }] : []),
];
}
+1
View File
@@ -402,6 +402,7 @@ func (b *builder) controllerOptions(runner agent.Runner, executor *agent.Agent,
SubagentGate: t.gate,
Label: label,
ModelRef: b.model.ref,
ModelModes: config.RequestModes(entry),
SystemPrompt: b.prompt.prompt,
SessionDir: b.session.dir,
Host: t.host,
@@ -0,0 +1,196 @@
package boot
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"sync"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/session/control"
)
// modeWire records every Responses body the real provider sends.
type modeWire struct {
mu sync.Mutex
bodies []map[string]any
}
func (w *modeWire) serve(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
body := map[string]any{}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
w.mu.Lock()
w.bodies = append(w.bodies, body)
w.mu.Unlock()
rw.Header().Set("Content-Type", "text/event-stream")
_, _ = rw.Write([]byte(answeredResponsesSSE))
}))
t.Cleanup(srv.Close)
return srv
}
// answeredResponsesSSE is one turn with a visible answer, so a run settles on
// its first request rather than retrying for one.
const answeredResponsesSSE = "data: {\"type\":\"response.output_text.delta\",\"item_id\":\"m1\",\"content_index\":0,\"delta\":\"ok\"}\n\n" +
"data: {\"type\":\"response.output_text.done\",\"item_id\":\"m1\",\"content_index\":0,\"text\":\"ok\"}\n\n" +
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\",\"status\":\"completed\",\"output\":[]}}\n\n"
// loopBodies are the main loop's requests: the ones carrying the tool surface.
func (w *modeWire) loopBodies() []map[string]any {
w.mu.Lock()
defer w.mu.Unlock()
var out []map[string]any
for _, b := range w.bodies {
if _, ok := b["tools"]; ok {
out = append(out, b)
}
}
return out
}
// buildModeSession assembles the real stack for one Responses provider whose
// wire is the recording server, while its base_url stays the vendor's host.
func buildModeSession(t *testing.T, wire *modeWire, baseURL, model string) *control.Controller {
t.Helper()
isolateConfigHome(t)
dir := robustTempDir(t)
t.Chdir(dir)
t.Setenv("BOOT_MODE_TEST_KEY", "sk-test")
srv := wire.serve(t)
writeFile(t, dir, "reasonix.toml", `
default_model = "oai"
[agent]
system_prompt = "BASE"
[[providers]]
name = "oai"
kind = "responses"
base_url = "`+baseURL+`"
request_url = "`+srv.URL+`"
model = "`+model+`"
responses_mode = "stateless"
api_key_env = "BOOT_MODE_TEST_KEY"
`)
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
if err != nil {
t.Fatalf("Build: %v", err)
}
t.Cleanup(ctrl.Close)
return ctrl
}
func reasoningMode(body map[string]any) any {
reasoning, _ := body["reasoning"].(map[string]any)
return reasoning["mode"]
}
// samePrefix reports whether later carries earlier's cacheable prefix
// unchanged: the same instructions and tools, and earlier's input as its head.
func samePrefix(earlier, later map[string]any) bool {
if !reflect.DeepEqual(earlier["instructions"], later["instructions"]) || !reflect.DeepEqual(earlier["tools"], later["tools"]) {
return false
}
head, _ := earlier["input"].([]any)
tail, _ := later["input"].([]any)
return len(head) > 0 && len(tail) >= len(head) && reflect.DeepEqual(tail[:len(head)], head)
}
// Pro reaches the Responses body as reasoning.mode through the real assembly,
// and switching it on mid-conversation leaves the cacheable prefix — the
// instructions, the tools, the input already sent — byte-for-byte as it was.
func TestEffectProModeReachesTheResponsesRequest(t *testing.T) {
wire := &modeWire{}
ctrl := buildModeSession(t, wire, "https://api.openai.com/v1", "gpt-5.6-sol")
modes := ctrl.ModelModes()
if len(modes) != 1 || modes[0].ID != "pro" || !modes[0].Costlier || modes[0].Active {
t.Fatalf("gpt-5.6-sol on the Responses API lists %+v, want one inactive costlier pro", modes)
}
if err := ctrl.Run(context.Background(), "reply ok"); err != nil {
t.Fatalf("Run off: %v", err)
}
if err := ctrl.SetModelMode("pro"); err != nil {
t.Fatalf("SetModelMode(pro): %v", err)
}
if !ctrl.ModelModes()[0].Active {
t.Fatal("pro is not reported active after it was set")
}
if err := ctrl.Run(context.Background(), "reply ok again"); err != nil {
t.Fatalf("Run on: %v", err)
}
bodies := wire.loopBodies()
if len(bodies) < 2 {
t.Fatalf("recorded %d loop requests, want one per run", len(bodies))
}
off, on := bodies[0], bodies[len(bodies)-1]
if reasoningMode(off) != nil {
t.Fatalf("mode off sent reasoning=%v", off["reasoning"])
}
if reasoningMode(on) != "pro" {
t.Fatalf("mode on sent reasoning=%v, want mode pro", on["reasoning"])
}
if !samePrefix(off, on) {
t.Fatalf("turning pro on moved the cacheable prefix:\noff=%v\non=%v", off, on)
}
}
// A model or wire nobody declared pro for refuses it at the controller with a
// typed error, and the request never carries it even when the executor is
// told to directly: the adapter sends only what the endpoint declared.
func TestEffectUndeclaredModeNeverReachesTheRequest(t *testing.T) {
for _, tc := range []struct{ name, baseURL, model string }{
{"a relay serving gpt-6", "https://relay.example.com/v1", "gpt-6-sol"},
{"a model with no modes", "https://api.openai.com/v1", "gpt-4.1"},
} {
t.Run(tc.name, func(t *testing.T) {
wire := &modeWire{}
ctrl := buildModeSession(t, wire, tc.baseURL, tc.model)
if modes := ctrl.ModelModes(); modes != nil {
t.Fatalf("lists %+v, want no modes", modes)
}
if err := ctrl.SetModelMode("pro"); !errors.Is(err, control.ErrModelModeUnsupported) {
t.Fatalf("SetModelMode(pro) = %v, want ErrModelModeUnsupported", err)
}
ctrl.Executor().SetRequestMode("pro")
if err := ctrl.Run(context.Background(), "reply ok"); err != nil {
t.Fatalf("Run: %v", err)
}
if len(wire.loopBodies()) == 0 {
t.Fatal("no loop request reached the wire")
}
for _, body := range wire.loopBodies() {
if mode := reasoningMode(body); mode != nil {
t.Fatalf("sent reasoning.mode=%v to an endpoint that declared none", mode)
}
}
})
}
}
// A rebuild carries the mode onto a model that declares it and drops it onto
// one that does not, so a model switch is what turns pro off.
func TestEffectModelSwitchCarriesOnlyADeclaredMode(t *testing.T) {
from := buildModeSession(t, &modeWire{}, "https://api.openai.com/v1", "gpt-5.6-sol")
if err := from.SetModelMode("pro"); err != nil {
t.Fatalf("SetModelMode(pro): %v", err)
}
for _, tc := range []struct {
model, want string
}{{"gpt-6-sol", "pro"}, {"gpt-4.1", ""}} {
to := buildModeSession(t, &modeWire{}, "https://api.openai.com/v1", tc.model)
if err := ApplyRuntimeMigration(to, from, CaptureRuntimeMigration(from)); err != nil {
t.Fatalf("ApplyRuntimeMigration to %s: %v", tc.model, err)
}
if got := to.ModelMode(); got != tc.want {
t.Fatalf("switching to %s left mode %q, want %q", tc.model, got, tc.want)
}
}
}
@@ -27,6 +27,7 @@ func providerConfig(e *config.ProviderEntry, proxy netclient.ProxySpec) provider
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"supported_efforts": config.RequestEffortLevels(e),
"reasoning_modes": config.RequestReasoningModes(e),
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
"max_output_tokens": e.MaxOutputTokens,
"chat_url": e.ChatURL,
+8
View File
@@ -2,6 +2,7 @@ package boot
import (
"context"
"errors"
"fmt"
"reasonix/internal/state/sessionstore"
"strings"
@@ -147,6 +148,7 @@ type RuntimeMigration struct {
planMode bool
goal string
goalRunning bool
modelMode string
}
// CaptureRuntimeMigration reads the outgoing runtime's state. Capture it
@@ -163,6 +165,7 @@ func CaptureRuntimeMigration(old control.SessionAPI) RuntimeMigration {
}
if ctrl, ok := old.(*control.Controller); ok {
m.authorizations = ctrl.SessionAuthorizations()
m.modelMode = ctrl.ModelMode()
}
return m
}
@@ -191,6 +194,11 @@ func ApplyRuntimeMigration(ctrl, old *control.Controller, m RuntimeMigration) er
ctrl.InheritLifecycleFrom(old)
}
ctrl.RestoreSessionAuthorizations(m.authorizations)
// A mode survives only onto a model that declares it: switching to one
// that does not is what turns it off, rather than a request it would 400.
if err := ctrl.SetModelMode(m.modelMode); err != nil && !errors.Is(err, control.ErrModelModeUnsupported) {
return err
}
return nil
}
@@ -57,6 +57,8 @@ type modelReasoningCapability struct {
Kinds []string
// ContextWindow is a fact about the model; zero means none is established.
ContextWindow int
// Modes are the optional run modes the model takes; see RequestModes.
Modes []modelModeDecl
}
// modelReasoningCapabilities is keyed by exact model id, so a gateway serving a
@@ -115,6 +117,7 @@ func gpt56Capability() modelReasoningCapability {
Aliases: map[string]string{"minimal": "low"},
Vendor: openAIVendor,
Kinds: []string{kindOpenAI, kindResponses},
Modes: []modelModeDecl{openAIProMode},
}
}
@@ -128,6 +131,7 @@ func gpt6Capability(levels []string, def string) modelReasoningCapability {
Aliases: map[string]string{"minimal": "low"},
Vendor: openAIVendor,
Kinds: []string{kindResponses},
Modes: []modelModeDecl{openAIProMode},
}
}
+72
View File
@@ -0,0 +1,72 @@
package config
import "strings"
// ModelMode is an optional way a model can be asked to run, declared beside
// its effort ladder. It is a request parameter: it never reaches the prompt.
type ModelMode struct {
ID string
// LabelKey and HintKey name the frontend's strings for the switch.
LabelKey string
HintKey string
// ReasoningMode is the Responses reasoning.mode value the mode sends.
ReasoningMode string
// Costlier marks a mode the vendor documents as spending more per turn.
Costlier bool
}
// modelModeDecl binds a mode to the wires and the endpoint it was established
// on. Both are its own: a model's ladder may reach a wire its modes do not.
type modelModeDecl struct {
Mode ModelMode
Kinds []string
Vendor vendorEndpoint
}
// openAIProMode is developers.openai.com/api/docs/guides/reasoning: GPT-5.6
// and GPT-6 take reasoning.mode "pro" on the Responses API only, independent
// of reasoning.effort, billed at the model's rates for more tokens.
var openAIProMode = modelModeDecl{
Mode: ModelMode{
ID: "pro", LabelKey: "model_mode.pro", HintKey: "model_mode.pro.hint",
ReasoningMode: "pro", Costlier: true,
},
Kinds: []string{kindResponses},
Vendor: openAIVendor,
}
// RequestModes is the modes a request for this entry may carry: the model
// table's declarations whose wire and endpoint match the entry in hand. A relay
// serving the same model id gets none — nobody has shown it forwards them.
func RequestModes(e *ProviderEntry) []ModelMode {
if e == nil {
return nil
}
cap, ok := modelReasoningCapabilities[strings.ToLower(strings.TrimSpace(e.Model))]
if !ok {
return nil
}
var out []ModelMode
for _, decl := range cap.Modes {
if containsString(decl.Kinds, e.Kind) && servedByVendor(e, decl.Vendor) {
out = append(out, decl.Mode)
}
}
return out
}
// RequestReasoningModes projects RequestModes onto the Responses wire: mode id
// to the reasoning.mode value it sends. A provider sends nothing outside it.
func RequestReasoningModes(e *ProviderEntry) map[string]string {
modes := RequestModes(e)
if len(modes) == 0 {
return nil
}
out := make(map[string]string, len(modes))
for _, mode := range modes {
if mode.ReasoningMode != "" {
out[mode.ID] = mode.ReasoningMode
}
}
return out
}
@@ -0,0 +1,36 @@
package config
import "testing"
func TestRequestModesByModelWireAndEndpoint(t *testing.T) {
const official = "https://api.openai.com/v1"
cases := []struct {
name, kind, baseURL, model string
wantPro bool
}{
{"gpt-5.6 alias on Responses", "responses", official, "gpt-5.6", true},
{"gpt-5.6-sol on Responses", "responses", official, "gpt-5.6-sol", true},
{"gpt-5.6-terra on Responses", "responses", official, "gpt-5.6-terra", true},
{"gpt-5.6-luna on Responses", "responses", official, "gpt-5.6-luna", true},
{"gpt-6-sol on Responses", "responses", official, "gpt-6-sol", true},
{"gpt-6-luna on Responses", "responses", official, "gpt-6-luna", true},
{"gpt-6-astra on Responses", "responses", official, "GPT-6-Astra", true},
{"gpt-5.6 on Chat Completions", "openai", official, "gpt-5.6-sol", false},
{"gpt-6 on a relay", "responses", "https://relay.example.com/v1", "gpt-6-sol", false},
{"a model that declares none", "responses", official, "gpt-4.1", false},
{"deepseek", "openai", "https://api.deepseek.com", "deepseek-v4-pro", false},
{"qwen3.8", "openai", "https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen3.8-max", false},
}
for _, tc := range cases {
e := &ProviderEntry{Name: "p", Kind: tc.kind, BaseURL: tc.baseURL, Model: tc.model}
modes := RequestModes(e)
wire := RequestReasoningModes(e)
gotPro := len(modes) == 1 && modes[0].ID == "pro" && modes[0].Costlier && wire["pro"] == "pro"
if gotPro != tc.wantPro || (!tc.wantPro && (len(modes) != 0 || len(wire) != 0)) {
t.Errorf("%s: modes %+v wire %v, want pro=%v", tc.name, modes, wire, tc.wantPro)
}
}
if RequestModes(nil) != nil {
t.Fatal("a nil entry declared modes")
}
}
+3
View File
@@ -201,6 +201,9 @@ type Request struct {
// entirely — the common path must stay byte-stable for prompt caching.
ResponseFormat *ResponseFormat `json:"ResponseFormat,omitempty"`
EffortOverride string `json:"EffortOverride,omitempty"` // per-call reasoning-depth override; adapters apply it only when the endpoint's effort vocabulary accepts it
// Mode is the session's model mode id. An adapter sends only a mode its
// config declared for this endpoint, so an unknown one reaches no wire.
Mode string `json:"Mode,omitempty"`
}
// ResponseFormat asks a provider to constrain its output shape.
+40
View File
@@ -0,0 +1,40 @@
package serve
import (
"encoding/json"
"errors"
"net/http"
"strings"
"reasonix/internal/session/control"
)
const codeModelModeUnsupported = "model_mode.unsupported"
// modelMode switches the session's model mode; an empty mode turns it off. It
// is session state on the controller, never written to config, and a model
// that does not declare the mode is refused rather than sent a request it 400s.
func (s *Server) modelMode(w http.ResponseWriter, r *http.Request) {
var body struct {
Mode *string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
badBody(w)
return
}
if body.Mode == nil {
missingField(w, "mode")
return
}
mode := strings.TrimSpace(*body.Mode)
if err := s.ctl().SetModelMode(mode); err != nil {
if errors.Is(err, control.ErrModelModeUnsupported) {
writeErr(w, http.StatusBadRequest, refusal(http.StatusBadRequest, codeModelModeUnsupported, err,
map[string]any{"mode": mode, "model": currentModelRef(s.ctl())}))
return
}
writeErr(w, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -0,0 +1,87 @@
package serve
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func modeServer(t *testing.T, modes []config.ModelMode) *httptest.Server {
t.Helper()
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
bc := NewBroadcaster()
ctrl := control.New(control.Options{Runner: fakeRunner{}, Sink: bc, ModelRef: "oai/gpt-5.6-sol", ModelModes: modes})
t.Cleanup(ctrl.Close)
srv := httptest.NewServer(operatorHandler(New(ctrl, bc, config.ServeConfig{})))
t.Cleanup(srv.Close)
return srv
}
func postMode(t *testing.T, srv *httptest.Server, body string) (int, Reason) {
t.Helper()
res, err := http.Post(srv.URL+"/model-mode", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var reason Reason
_ = json.NewDecoder(res.Body).Decode(&reason)
return res.StatusCode, reason
}
func statusModes(t *testing.T, srv *httptest.Server) (json.RawMessage, bool) {
t.Helper()
res, err := http.Get(srv.URL + "/status")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var body map[string]json.RawMessage
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode status: %v", err)
}
modes, ok := body["modes"]
return modes, ok
}
// A model that declares no mode is refused one with a code the frontend can
// say, not a 500 and not a request the endpoint would answer with 400.
func TestModelModeRefusedWhereTheModelDeclaresNone(t *testing.T) {
srv := modeServer(t, nil)
status, reason := postMode(t, srv, `{"mode":"pro"}`)
if status != http.StatusBadRequest || reason.Code != codeModelModeUnsupported {
t.Fatalf("status %d code %q, want 400 %s", status, reason.Code, codeModelModeUnsupported)
}
if status, _ := postMode(t, srv, `{"mode":""}`); status != http.StatusNoContent {
t.Fatalf("turning off an absent mode answered %d, want 204", status)
}
if _, ok := statusModes(t, srv); ok {
t.Fatal("status lists modes for a model that declares none")
}
}
func TestModelModeAcceptedAndListedWhereDeclared(t *testing.T) {
srv := modeServer(t, config.RequestModes(&config.ProviderEntry{
Kind: "responses", BaseURL: "https://api.openai.com/v1", Model: "gpt-5.6-sol",
}))
if status, _ := postMode(t, srv, `{"mode":"pro"}`); status != http.StatusNoContent {
t.Fatalf("declared mode answered %d, want 204", status)
}
if status, reason := postMode(t, srv, `{}`); status != http.StatusBadRequest || reason.Code != codeMissingField {
t.Fatalf("missing mode answered %d %q, want 400 %s", status, reason.Code, codeMissingField)
}
raw, ok := statusModes(t, srv)
if !ok {
t.Fatal("status lists no modes for a model that declares pro")
}
var modes []control.ModelModeView
if err := json.Unmarshal(raw, &modes); err != nil || len(modes) != 1 || modes[0].ID != "pro" || !modes[0].Costlier {
t.Fatalf("status modes = %s (%v), want one costlier pro", raw, err)
}
}
+1
View File
@@ -46,6 +46,7 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /preset", s.preset)
mux.HandleFunc("POST /model", s.model)
mux.HandleFunc("POST /effort", s.effort)
mux.HandleFunc("POST /model-mode", s.modelMode)
mux.HandleFunc("POST /compact", s.compact)
s.registerCompactionRoutes(mux)
s.registerProgressWatchRoutes(mux)
+5
View File
@@ -207,6 +207,11 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
sess["visionDeclared"] = config.VisionDeclared(entry)
}
}
// Only a model that declares modes lists any, which is what keeps the
// switch off every other model's effort menu.
if modes := s.ctl().ModelModes(); len(modes) > 0 {
sess["modes"] = modes
}
sess["sessionCostQuote"] = s.bc.SessionCostQuote()
if j := s.ctl().Jobs(); len(j) > 0 {
sess["jobs"] = j
@@ -0,0 +1,50 @@
package responses
import (
"testing"
"reasonix/internal/contract/provider"
)
func reasoningOf(t *testing.T, cfg Config, mode string) map[string]any {
t.Helper()
c := New(cfg).(*client)
body, _, _ := c.buildRequestBody(provider.Request{
Messages: []provider.Message{{Role: provider.RoleUser, Content: "hi"}},
Mode: mode,
})
reasoning, _ := body["reasoning"].(map[string]any)
return reasoning
}
func TestDeclaredModeRidesReasoningBesideEffort(t *testing.T) {
cfg := Config{BaseURL: "https://api.openai.com/v1", Model: "gpt-5.6-sol", Effort: "high",
ReasoningModes: map[string]string{"pro": "pro"}}
got := reasoningOf(t, cfg, "pro")
if got["mode"] != "pro" || got["effort"] != "high" {
t.Fatalf("reasoning = %v, want mode pro beside effort high", got)
}
cfg.Effort = ""
if got := reasoningOf(t, cfg, "pro"); got["mode"] != "pro" || got["effort"] != nil {
t.Fatalf("reasoning without effort = %v, want mode alone", got)
}
}
func TestUndeclaredModeNeverReachesTheWire(t *testing.T) {
declared := Config{BaseURL: "https://api.openai.com/v1", Model: "gpt-5.6-sol", Effort: "high",
ReasoningModes: map[string]string{"pro": "pro"}}
undeclared := Config{BaseURL: "https://relay.example.com/v1", Model: "gpt-5.6-sol", Effort: "high"}
for _, tc := range []struct {
name string
cfg Config
mode string
}{
{"off", declared, ""},
{"a mode the endpoint did not declare", declared, "deep"},
{"an endpoint that declared none", undeclared, "pro"},
} {
if got := reasoningOf(t, tc.cfg, tc.mode); got["mode"] != nil {
t.Errorf("%s: reasoning = %v, want no mode", tc.name, got)
}
}
}
+16 -3
View File
@@ -48,10 +48,12 @@ func newFromConfig(cfg provider.Config) (provider.Provider, error) {
keySource, _ := cfg.Extra["api_key_source"].(string)
maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int)
requestURL, _ := cfg.Extra["request_url"].(string)
reasoningModes, _ := cfg.Extra["reasoning_modes"].(map[string]string)
return New(Config{
Name: cfg.Name, APIKey: cfg.APIKey, APIKeyFunc: cfg.APIKeyFunc, BaseURL: cfg.BaseURL, Model: cfg.Model,
Effort: effort, Mode: mode, Stateful: stateful, WebSearch: webSearch, Proxy: proxy,
KeyEnv: keyEnv, KeySource: keySource, MaxOutputTokens: maxOutputTokens, RequestURL: requestURL,
ReasoningModes: reasoningModes,
// Extra 原样透传:vision 等能力开关由调用方(boot/CLI)写入
// cfg.Extra,factory 若丢弃则 New() 读不到(评审 #7234 第 3 点)。
Extra: cfg.Extra,
@@ -72,6 +74,9 @@ type Config struct {
Proxy netclient.ProxySpec
KeyEnv, KeySource string
RequestURL string // optional exact Responses request URL; empty derives from BaseURL
// ReasoningModes maps a model mode id to the reasoning.mode it sends; a
// request naming any other mode sends none.
ReasoningModes map[string]string
// MaxOutputTokens is the total provider output budget. Zero enables Reasonix's
// 32K reasoning safety default on official DeepSeek and otherwise omits the
// field; thinking-disabled DeepSeek requests and negative values omit it.
@@ -114,6 +119,7 @@ type client struct {
session sessionHeaders
maxOutputTokens int
vision bool // model accepts image input; embed Images as input_image parts
reasoningModes map[string]string
http *http.Client
idleTimeout time.Duration
authed atomic.Bool
@@ -172,8 +178,8 @@ func New(cfg Config) provider.Provider {
name: cfg.Name, apiKey: cfg.apiKeyResolver(), keyEnv: cfg.KeyEnv, keySource: cfg.KeySource,
baseURL: baseURL, requestURL: requestURL, model: cfg.Model, effort: cfg.Effort,
vendor: vendor, caps: cap, mode: cfg.mode(), session: sessionHeaders{dashScopeCache: sessionCache, openCode: provider.NewOpenCodeSessionID()}, webSearch: cfg.WebSearch, maxOutputTokens: maxOutputTokens,
vision: vision,
http: httpClient, idleTimeout: defaultStreamIdleTimeout,
vision: vision, reasoningModes: cfg.ReasoningModes,
http: httpClient, idleTimeout: defaultStreamIdleTimeout,
}
}
@@ -327,8 +333,15 @@ func (c *client) buildRequestBody(req provider.Request) (map[string]any, bool, [
case "disabled", "off":
effort = "none"
}
reasoning := map[string]any{}
if effort != "" {
body["reasoning"] = map[string]any{"effort": effort}
reasoning["effort"] = effort
}
if mode := c.reasoningModes[req.Mode]; req.Mode != "" && mode != "" {
reasoning["mode"] = mode
}
if len(reasoning) > 0 {
body["reasoning"] = reasoning
}
maxOutputTokens := req.MaxTokens
if maxOutputTokens == 0 {
+32
View File
@@ -0,0 +1,32 @@
package agent
import "sync/atomic"
// requestModeState is the model mode id every main-loop request carries. The
// controller validates it against the model's declarations; the adapter still
// sends only what its endpoint declared.
type requestModeState struct{ id atomic.Pointer[string] }
func (s *requestModeState) set(id string) {
if id == "" {
s.id.Store(nil)
return
}
s.id.Store(&id)
}
func (s *requestModeState) get() string {
if p := s.id.Load(); p != nil {
return *p
}
return ""
}
func (s *requestModeState) reset() { s.id.Store(nil) }
// SetRequestMode selects the model mode later requests carry; "" turns it off.
// It rides the request, never the prompt, so the cached prefix is unchanged.
func (a *Agent) SetRequestMode(id string) { a.sess.mode.set(id) }
// RequestMode is the model mode the next request carries, "" when off.
func (a *Agent) RequestMode() string { return a.sess.mode.get() }
@@ -0,0 +1,50 @@
package agent
import (
"context"
"reflect"
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
"reasonix/internal/state/sessionstore"
)
func modeTestAgent() *Agent {
sess := &sessionstore.Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "task"},
}}
return New(&fakeProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{ContextWindow: 50_000, ModelRef: "m"}, event.Discard)
}
// The mode rides the request and nothing else: the messages and tools a
// request carries are the same bytes with it on as with it off.
func TestRequestModeRidesTheRequestNotThePrefix(t *testing.T) {
a := modeTestAgent()
off, err := a.buildSamplingRequest(context.Background(), CompactionTriggerPressure)
if err != nil {
t.Fatalf("buildSamplingRequest: %v", err)
}
a.SetRequestMode("pro")
on, err := a.buildSamplingRequest(context.Background(), CompactionTriggerPressure)
if err != nil {
t.Fatalf("buildSamplingRequest: %v", err)
}
if off.req.Mode != "" || on.req.Mode != "pro" {
t.Fatalf("mode off=%q on=%q, want \"\" and pro", off.req.Mode, on.req.Mode)
}
if !reflect.DeepEqual(off.req.Messages, on.req.Messages) || !reflect.DeepEqual(off.req.Tools, on.req.Tools) {
t.Fatal("turning the mode on changed the request's messages or tools")
}
}
func TestANewConversationStartsWithTheModeOff(t *testing.T) {
a := modeTestAgent()
a.SetRequestMode("pro")
a.SetSession(&sessionstore.Session{})
if got := a.RequestMode(); got != "" {
t.Fatalf("mode after SetSession = %q, want off", got)
}
}
@@ -129,6 +129,9 @@ func (a *Agent) buildSamplingRequest(ctx context.Context, trigger string) (sampl
if err != nil {
return samplingRequest{}, err
}
// Host-owned and set after the extension ruling: the payload it rewrites
// has no mode, and a replaced request must not drop the session's choice.
req.Mode = a.sess.mode.get()
return samplingRequest{req: req}, nil
}
+3
View File
@@ -28,6 +28,8 @@ type sessionRuntime struct {
cacheMiss atomic.Int64
missingReasoning missingReasoningWatch
// mode is the model mode the conversation asked for; a new one starts off.
mode requestModeState
// path is rebound by preflight when a transcript is bound, so reset leaves
// it to its owner rather than blanking it.
@@ -61,6 +63,7 @@ func (r *sessionRuntime) reset(s *sessionstore.Session) {
r.cacheMiss.Store(0)
r.output.reset()
r.missingReasoning = missingReasoningWatch{}
r.mode.reset()
r.win.reset()
}
@@ -20,6 +20,7 @@ var sessionReset = map[string]bool{
"cacheHit": true,
"cacheMiss": true,
"missingReasoning": true,
"mode": true,
"win": true, // windowState.reset, held to its own lists below
}
+8 -9
View File
@@ -298,15 +298,14 @@ type Options struct {
PromptRefiner *promptrefine.Refiner
Sink event.Sink
Policy permission.Policy
// SubagentGate is the shared, mutable gate every headless-only sub-agent
// surface (task, writer-capable skill sub-agents, planner) reads from. Nil
// disables gating for those surfaces same as before this field existed.
// SetToolApprovalMode and ApplyHeadlessApprovalMode call Update on it so a
// runtime approval-mode switch reaches sub-agents, not just the parent
// executor's own gate.
SubagentGate *SharedHeadlessGate
Label string
ModelRef string
// SubagentGate is the shared gate every headless-only sub-agent surface
// reads; nil disables gating there. The approval-mode setters Update it so
// a runtime switch reaches sub-agents, not only the executor's own gate.
SubagentGate *SharedHeadlessGate
Label string
ModelRef string
// ModelModes are the optional modes the session's model declares.
ModelModes []config.ModelMode
SystemPrompt string
SessionDir string
SessionPath string
@@ -5,6 +5,7 @@ import (
"reasonix/internal/state/sessionstore"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/ext/extension"
"reasonix/internal/ext/hook"
@@ -56,6 +57,7 @@ type controllerDeps struct {
label string
modelRef string
modelModes []config.ModelMode // what SetModelMode accepts; see model_modes.go
sessionDir string
// skills owns the session's discovered skills (enabled subset, full set, and
// the reloadable stores) — the skills slice of the Capabilities concern. See
@@ -134,6 +136,7 @@ func newControllerDeps(opts Options, sink event.Sink, usageTee *goalUsageTee, ru
subagentGate: opts.SubagentGate,
label: opts.Label,
modelRef: opts.ModelRef,
modelModes: opts.ModelModes,
sessionDir: opts.SessionDir,
skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore, opts.DisableImplicitSkillInvocation),
skillRunner: opts.SkillRunner,
+63
View File
@@ -0,0 +1,63 @@
package control
import (
"errors"
"fmt"
)
// ErrModelModeUnsupported is a mode the session's model does not declare.
var ErrModelModeUnsupported = errors.New("model mode not declared for this model")
// ModelModeView is one optional mode the session's model declares, as a
// frontend draws its switch.
type ModelModeView struct {
ID string `json:"id"`
LabelKey string `json:"labelKey"`
HintKey string `json:"hintKey"`
Costlier bool `json:"costlier"`
Active bool `json:"active"`
}
// ModelModes lists the modes the session's model declares, nil when it
// declares none — which is what keeps the switch off every other model's menu.
func (c *Controller) ModelModes() []ModelModeView {
if len(c.modelModes) == 0 {
return nil
}
active := c.ModelMode()
out := make([]ModelModeView, 0, len(c.modelModes))
for _, m := range c.modelModes {
out = append(out, ModelModeView{ID: m.ID, LabelKey: m.LabelKey, HintKey: m.HintKey, Costlier: m.Costlier, Active: m.ID == active})
}
return out
}
// ModelMode is the mode the next request carries, "" when off.
func (c *Controller) ModelMode() string {
if c.executor == nil {
return ""
}
return c.executor.RequestMode()
}
// SetModelMode selects a declared mode for this conversation; "" turns it off.
// It rides the request, so it takes effect on the next model call and leaves
// the cached prefix alone.
func (c *Controller) SetModelMode(id string) error {
if id != "" && !c.declaresModelMode(id) {
return fmt.Errorf("%w: %q on %s", ErrModelModeUnsupported, id, c.modelRef)
}
if c.executor != nil {
c.executor.SetRequestMode(id)
}
return nil
}
func (c *Controller) declaresModelMode(id string) bool {
for _, m := range c.modelModes {
if m.ID == id {
return true
}
}
return false
}
+2
View File
@@ -346,6 +346,8 @@ type Settings interface {
SetResponseLanguage(lang string)
SetReasoningLanguage(lang string)
SetDisplayRecorder(fn func(content, display string))
ModelModes() []ModelModeView
SetModelMode(id string) error
}
// Provenance is input a networked frontend relays for someone acting from a
+1
View File
@@ -73,6 +73,7 @@ var mirroredWireTypes = []wireMirror{
// The fold bounds and the one in force: a field the panel cannot read is a
// threshold a user sets and never sees applied.
{"internal/session/control/compaction_settings.go", "CompactionSettings", tsModelFile, "CompactionSettings"},
{"internal/session/control/model_modes.go", "ModelModeView", tsModelFile, "ModelMode"},
// The completion summary is the turn's own verdict on itself; a gap kind the
// desktop cannot read is a turn it shows as clean.
{"internal/contract/eventwire/wire.go", "CompletionSummary", tsWireFile, "CompletionSummary"},