mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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",
|
||||
|
||||
@@ -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} 字节,无法优化",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(""));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 }] : []),
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Reference in new issue
Block a user