feat: drop auxiliary model from thread selection and registry defaults

ThreadModelSelection is now "inherit" or { primary, reasoning_effort };
the BFF validator tolerates and drops legacy auxiliary keys, the runs
route no longer snapshots an auxiliary ref, and the Registry Editor's
default-auxiliary field is removed, matching the backend's single-role
snapshot resolution.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-23 10:19:03 +08:00
parent 7a61ac11cb
commit 222cb0b84e
11 changed files with 277 additions and 69 deletions
+4
View File
@@ -18,6 +18,10 @@ EVOSCIENTIST_BACKEND_URL=http://127.0.0.1:6174
# Deployment workspace root. This is not a browser-selectable directory and is
# shared with the trusted EvoScientist backend. Defaults to ~/.evoscientist/workspace.
EVOSCIENTIST_WORKSPACE_DIR=
# Deployment id, matching the backend's EVOSCIENTIST_DEPLOYMENT_ID. Only needed
# when the backend was started manually (no deploy sidecar); otherwise the
# sidecar value wins. Defaults to a uuid derived from the workspace root.
EVOSCIENTIST_DEPLOYMENT_ID=
# Optional for local processes running as the same OS user: both sides share
# ~/.config/evoscientist/provider-admin-token automatically. Set this explicitly
# when the WebUI and backend run on different hosts, users, or containers.
@@ -224,8 +224,10 @@ export async function POST(request: NextRequest, context: RouteContext) {
model_selection_revision: selection.revision,
primary:
selectionRef === "inherit" ? null : selectionRef.primary,
auxiliary:
selectionRef === "inherit" ? null : selectionRef.auxiliary,
reasoning_effort:
selectionRef === "inherit"
? null
: (selectionRef.reasoning_effort ?? null),
},
}
);
+126 -4
View File
@@ -101,7 +101,7 @@ import { WorkspaceFileDialog } from "@/app/components/WorkspaceFileDialog";
import { MemoryFileDialog } from "@/app/components/MemoryFileDialog";
import { FILE_LINK_EVENT, type FileLinkEventDetail } from "@/lib/fileLink";
import { notifyWorkspaceChanged } from "@/lib/workspaceEvents";
import type { ModelRef } from "@/lib/modelRegistry";
import type { ModelRef, ReasoningEffort } from "@/lib/modelRegistry";
import {
modelRefLabel,
useAvailableModels,
@@ -446,6 +446,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
}, [modelPickerOpen]);
const {
models: selectableModels,
defaults: registryDefaults,
loading: modelRegistryLoading,
error: modelRegistryError,
} = useAvailableModels();
@@ -709,7 +710,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
// 1. Explicit per-thread selection (ModelRef picker) — the next run
// freezes exactly this into its runtime snapshot.
// 2. `inherit` — the registry default resolved server-side at snapshot
// creation; the pill shows the last observed model as a hint.
// creation; the pill shows the last observed model as a hint, or the
// registry default when no turn has run yet.
// Token/context usage is intentionally NOT shown — the backend doesn't
// persist usage_metadata, so it isn't reliably available here.
const currentModel = useMemo(() => {
@@ -734,8 +736,22 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
);
if (info) return info;
}
// Fresh thread on `inherit`: show the registry default so the picker
// entry point exists before the first turn (the run would use it).
const fallbackRef = registryDefaults?.primary ?? null;
if (fallbackRef) {
const match = selectableModels.find(
(m) =>
m.model_ref.provider_id === fallbackRef.provider_id &&
m.model_ref.model_key === fallbackRef.model_key
);
return {
name: match?.name ?? fallbackRef.model_key,
provider: match?.provider_name || fallbackRef.provider_id,
};
}
return null;
}, [messages, modelSelection, selectableModels]);
}, [messages, modelSelection, selectableModels, registryDefaults]);
// Bind captured sub-agent activity (keyed by subgraph namespace) to each task
// tool call → its live steps. B': match a finished sub-agent to a task by its
@@ -1626,7 +1642,6 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
try {
await setModelSelection({
primary: ref,
auxiliary: null,
});
toast.success(`Model set to ${modelKey}.`);
setModelPickerOpen(false);
@@ -2215,6 +2230,36 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
)}
</button>
)}
{currentModel && (
<ReasoningEffortSlider
disabled={modelSelection === "inherit"}
value={
modelSelection === "inherit"
? null
: (modelSelection.reasoning_effort ?? null)
}
onCommit={async (effort) => {
if (modelSelection === "inherit") return;
try {
await setModelSelection({
primary: modelSelection.primary,
reasoning_effort: effort,
});
toast.success(
effort === null
? "Reasoning effort back to the registry default."
: `Reasoning effort set to ${effort}.`
);
} catch (err) {
toast.error(
err instanceof Error
? `Couldn't update reasoning effort: ${err.message}`
: "Couldn't update reasoning effort — try again."
);
}
}}
/>
)}
{threadId && (
<button
type="button"
@@ -2433,3 +2478,80 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
);
ChatInterface.displayName = "ChatInterface";
const EFFORT_LEVELS: ReadonlyArray<ReasoningEffort> = [
"auto",
"low",
"medium",
"high",
];
function ReasoningEffortSlider({
disabled,
value,
onCommit,
}: {
disabled: boolean;
value: ReasoningEffort | null;
onCommit: (effort: ReasoningEffort | null) => void | Promise<void>;
}) {
const committedIndex =
value && value !== "auto" ? EFFORT_LEVELS.indexOf(value) : 0;
const [draft, setDraft] = useState<number | null>(null);
const shownIndex = draft ?? committedIndex;
const shown = EFFORT_LEVELS[shownIndex];
const commit = (index: number) => {
setDraft(null);
if (index !== committedIndex) {
void onCommit(index === 0 ? null : EFFORT_LEVELS[index]);
}
};
return (
<span
className={cn("flex items-center gap-1.5", disabled && "opacity-50")}
title={
disabled
? "Pick a model to override the registry-configured effort"
: "Reasoning effort for the primary model"
}
>
<input
type="range"
aria-label="Reasoning effort"
aria-valuetext={shown}
disabled={disabled}
min={0}
max={EFFORT_LEVELS.length - 1}
step={1}
value={shownIndex}
onChange={(event) => setDraft(Number(event.target.value))}
onPointerUp={(event) =>
commit(Number((event.target as HTMLInputElement).value))
}
onKeyUp={(event) => {
if (
event.key.startsWith("Arrow") ||
event.key === "Home" ||
event.key === "End"
) {
commit(Number((event.target as HTMLInputElement).value));
}
}}
onBlur={(event) => commit(Number(event.target.value))}
className="w-20 cursor-pointer accent-[var(--brand)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed"
/>
<span
className={cn(
"w-11 text-xs capitalize",
shownIndex === 0
? "text-muted-foreground"
: "font-medium text-foreground"
)}
>
{shown}
</span>
</span>
);
}
-27
View File
@@ -1001,9 +1001,6 @@ export function RegistryEditor() {
}
}
}
if (isAffected(next.defaults.auxiliary)) {
next.defaults.auxiliary = null;
}
if (isAffected(next.defaults.primary)) {
const replacement = enabledModelRefs(next)[0]?.ref ?? null;
if (!replacement) {
@@ -1204,30 +1201,6 @@ export function RegistryEditor() {
))}
</select>
</Field>
<Field label="Default auxiliary model">
<select
className={inputClass}
value={
draft.defaults.auxiliary ? modelRefKey(draft.defaults.auxiliary) : ""
}
onChange={(event) =>
setDraft({
...draft,
defaults: {
...draft.defaults,
auxiliary: refFromKey(event.target.value),
},
})
}
>
<option value="">— none (uses primary) —</option>
{defaultOptions.map((option) => (
<option key={modelRefKey(option.ref)} value={modelRefKey(option.ref)}>
{option.label}
</option>
))}
</select>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[14rem_1fr]">
+36 -11
View File
@@ -1,11 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import type { ModelRef, SelectableModel } from "@/lib/modelRegistry";
import type {
ModelRef,
RegistryDefaults,
SelectableModel,
} from "@/lib/modelRegistry";
const EMPTY: SelectableModel[] = [];
let cachedModels: Promise<SelectableModel[]> | null = null;
interface AvailableModelsResult {
models: SelectableModel[];
defaults: RegistryDefaults | null;
}
let cachedModels: Promise<AvailableModelsResult> | null = null;
const MODELS_CHANGED_EVENT = "evoscientist-models-changed";
export function invalidateAvailableModels(): void {
@@ -56,20 +65,32 @@ function parseSelectableModel(raw: unknown): SelectableModel | null {
};
}
function fetchSelectableModels(): Promise<SelectableModel[]> {
function parseDefaults(raw: unknown): RegistryDefaults | null {
if (!raw || typeof raw !== "object") return null;
const d = raw as { primary?: unknown };
return {
primary: isModelRef(d.primary) ? d.primary : null,
};
}
function fetchSelectableModels(): Promise<AvailableModelsResult> {
if (cachedModels) return cachedModels;
const pending = fetch("/api/models")
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const body = (await response.json()) as { models?: unknown };
if (!Array.isArray(body.models)) return EMPTY;
const body = (await response.json()) as {
models?: unknown;
defaults?: unknown;
};
const models: SelectableModel[] = [];
for (const raw of body.models) {
const parsed = parseSelectableModel(raw);
if (parsed) models.push(parsed);
if (Array.isArray(body.models)) {
for (const raw of body.models) {
const parsed = parseSelectableModel(raw);
if (parsed) models.push(parsed);
}
}
return models;
return { models, defaults: parseDefaults(body.defaults) };
})
.catch((error: unknown) => {
cachedModels = null;
@@ -86,10 +107,12 @@ function fetchSelectableModels(): Promise<SelectableModel[]> {
* mount retries. There is no hard-coded fallback list. */
export function useAvailableModels(): {
models: SelectableModel[];
defaults: RegistryDefaults | null;
loading: boolean;
error: string | null;
} {
const [models, setModels] = useState<SelectableModel[]>(EMPTY);
const [defaults, setDefaults] = useState<RegistryDefaults | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshRevision, setRefreshRevision] = useState(0);
@@ -107,13 +130,15 @@ export function useAvailableModels(): {
fetchSelectableModels()
.then((result) => {
if (cancelled) return;
setModels(result);
setModels(result.models);
setDefaults(result.defaults);
setError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load models.");
setModels(EMPTY);
setDefaults(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -124,7 +149,7 @@ export function useAvailableModels(): {
};
}, [refreshRevision]);
return { models, loading, error };
return { models, defaults, loading, error };
}
export function modelRefLabel(ref: ModelRef): string {
+26 -9
View File
@@ -170,18 +170,20 @@ function readSelectionFromMetadata(metadata: Record<string, unknown> | undefined
return { selection: "inherit", revision };
}
if (raw && typeof raw === "object") {
const candidate = raw as { primary?: unknown; auxiliary?: unknown };
if (
isModelRefShape(candidate.primary) &&
(candidate.auxiliary === null || candidate.auxiliary === undefined ||
isModelRefShape(candidate.auxiliary))
) {
// A legacy `auxiliary` key from pre-6.1 metadata is tolerated and ignored.
const candidate = raw as {
primary?: unknown;
reasoning_effort?: unknown;
};
if (isModelRefShape(candidate.primary)) {
const effort = candidate.reasoning_effort;
return {
selection: {
primary: candidate.primary,
auxiliary: isModelRefShape(candidate.auxiliary)
? candidate.auxiliary
: null,
reasoning_effort:
effort === "low" || effort === "medium" || effort === "high"
? effort
: null,
},
revision,
};
@@ -619,6 +621,21 @@ export function useChat({
createdThreadIdRef.current = createdThreadId;
threadIdRef.current = createdThreadId;
setFetchedThreadId(createdThreadId);
// Flush a staged pre-thread pick before returning: the caller creates
// the first run immediately after, and the run snapshot freezes
// whatever selection the thread metadata holds at that moment.
const pending = pendingSelectionRef.current;
if (pending) {
pendingSelectionRef.current = null;
createdThreadIdRef.current = null;
try {
await setThreadModelSelection(createdThreadId, pending, 0);
setModelSelectionState(pending);
setSelectionRevision(1);
} catch {
// The thread-id effect resyncs from metadata.
}
}
await setThreadId(createdThreadId);
return createdThreadId;
})();
+7 -3
View File
@@ -72,7 +72,6 @@ export interface ProviderConfig {
export interface RegistryDefaults {
primary: ModelRef | null;
auxiliary: ModelRef | null;
}
export interface RegistryV4 {
@@ -190,12 +189,17 @@ export interface SelectableModel {
export interface GetSelectableModelsResponse {
models: SelectableModel[];
defaults?: RegistryDefaults;
}
/** ThreadModelSelection (design doc 7.2). */
/** ThreadModelSelection (design doc 7.2). The optional reasoning_effort is a
* per-thread override frozen into the run snapshot for the primary role. */
export type ThreadModelSelection =
| "inherit"
| { primary: ModelRef; auxiliary: ModelRef | null };
| {
primary: ModelRef;
reasoning_effort?: ReasoningEffort | null;
};
export interface RegistryApiError {
code?: string;
+1 -1
View File
@@ -95,7 +95,7 @@ describe("modelsNeedingReverify", () => {
version: 4,
revision: 1,
state: "active",
defaults: { primary: null, auxiliary: null },
defaults: { primary: null },
providers: [provider],
} as unknown as RegistryV4;
}
+2 -1
View File
@@ -98,7 +98,8 @@ export async function getActiveDeployment(): Promise<ActiveDeployment> {
const deploymentId =
typeof sidecarDeploymentId === "string" && sidecarDeploymentId
? sidecarDeploymentId
: fallbackDeploymentId(workspaceRoot);
: process.env.EVOSCIENTIST_DEPLOYMENT_ID?.trim() ||
fallbackDeploymentId(workspaceRoot);
const apiUrl =
typeof sidecar?.api_url === "string" && sidecar.api_url
? sidecar.api_url
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { parseThreadModelSelection } from "@/lib/server/threadModelSelection";
const REF = { provider_id: "glm", model_key: "glm-5.2" };
describe("parseThreadModelSelection", () => {
it("passes through inherit", () => {
expect(parseThreadModelSelection("inherit")).toBe("inherit");
});
it("keeps an explicit reasoning effort", () => {
expect(
parseThreadModelSelection({
primary: REF,
reasoning_effort: "high",
})
).toEqual({ primary: REF, reasoning_effort: "high" });
});
it("normalizes auto and missing effort to null", () => {
expect(
parseThreadModelSelection({
primary: REF,
reasoning_effort: "auto",
})
).toEqual({ primary: REF, reasoning_effort: null });
expect(parseThreadModelSelection({ primary: REF })).toEqual({
primary: REF,
reasoning_effort: null,
});
});
it("tolerates and drops a legacy auxiliary key", () => {
expect(
parseThreadModelSelection({
primary: REF,
auxiliary: { provider_id: "ollama", model_key: "qwen3" },
reasoning_effort: "low",
})
).toEqual({ primary: REF, reasoning_effort: "low" });
});
it("rejects an unknown effort value", () => {
expect(() =>
parseThreadModelSelection({
primary: REF,
reasoning_effort: "extreme",
})
).toThrowError(/model_selection must be/);
});
});
+20 -11
View File
@@ -1,6 +1,11 @@
import type { ModelRef, ThreadModelSelection } from "@/lib/modelRegistry";
import type {
ModelRef,
ReasoningEffort,
ThreadModelSelection,
} from "@/lib/modelRegistry";
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const EFFORTS = new Set<ReasoningEffort>(["auto", "low", "medium", "high"]);
export class ThreadModelSelectionError extends Error {
readonly code: string;
@@ -24,28 +29,32 @@ function isModelRef(value: unknown): value is ModelRef {
);
}
/** Validates a browser-supplied `model_selection` payload (design doc 7.2). */
/** Validates a browser-supplied `model_selection` payload (design doc 7.2).
* `reasoning_effort` is optional; "auto" is normalized away so toggling the
* picker back to Auto keeps the selection (and its snapshot hash) stable. A
* legacy `auxiliary` key from pre-6.1 metadata is tolerated and dropped. */
export function parseThreadModelSelection(value: unknown): ThreadModelSelection {
if (value === "inherit") return "inherit";
if (
isPlainObject(value) &&
isModelRef(value.primary) &&
(value.auxiliary === null || isModelRef(value.auxiliary))
(value.reasoning_effort === undefined ||
value.reasoning_effort === null ||
(typeof value.reasoning_effort === "string" &&
EFFORTS.has(value.reasoning_effort as ReasoningEffort)))
) {
const effort = value.reasoning_effort;
return {
primary: { provider_id: value.primary.provider_id, model_key: value.primary.model_key },
auxiliary:
value.auxiliary === null
? null
: {
provider_id: value.auxiliary.provider_id,
model_key: value.auxiliary.model_key,
},
reasoning_effort:
typeof effort === "string" && effort !== "auto"
? (effort as ReasoningEffort)
: null,
};
}
throw new ThreadModelSelectionError(
"INVALID_REQUEST",
"model_selection must be \"inherit\" or { primary: ModelRef, auxiliary: ModelRef | null }."
"model_selection must be \"inherit\" or { primary: ModelRef, reasoning_effort?: \"low\" | \"medium\" | \"high\" | null }."
);
}