Gateway/TUI: enforce strict model refs and sync selected model

This commit is contained in:
Gustavo Madeira Santana
2026-02-24 21:56:15 -05:00
parent 91449c809d
commit 0da3cd2f18
11 changed files with 346 additions and 15 deletions

View File

@@ -285,6 +285,104 @@ describe("model-selection", () => {
ref: { provider: "anthropic", model: "claude-sonnet-4-6" },
});
});
it("rejects non-alias direct model ids when strict mode is enabled", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as OpenClawConfig;
const catalog = [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
];
const result = resolveAllowedModelRef({
cfg,
catalog,
raw: "claude-sonnet-4-6",
defaultProvider: "openai",
defaultModel: "gpt-5.2",
requireProviderOrAlias: true,
});
expect(result).toEqual({
error: "invalid model: claude-sonnet-4-6 (use provider/model or alias)",
});
});
it("accepts aliases when strict mode is enabled", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as OpenClawConfig;
const catalog = [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
];
const result = resolveAllowedModelRef({
cfg,
catalog,
raw: "sonnet",
defaultProvider: "openai",
defaultModel: "gpt-5.2",
requireProviderOrAlias: true,
});
expect(result).toEqual({
key: "anthropic/claude-sonnet-4-6",
ref: { provider: "anthropic", model: "claude-sonnet-4-6" },
});
});
it("accepts provider-prefixed refs whose model id contains slashes in strict mode", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"vercel-ai-gateway/anthropic/claude-opus-4.6": { alias: "gateway-opus" },
},
},
},
} as OpenClawConfig;
const catalog = [
{
provider: "vercel-ai-gateway",
id: "anthropic/claude-opus-4.6",
name: "Gateway Claude Opus 4.6",
},
];
const result = resolveAllowedModelRef({
cfg,
catalog,
raw: "vercel-ai-gateway/anthropic/claude-opus-4.6",
defaultProvider: "openai",
defaultModel: "gpt-5.2",
requireProviderOrAlias: true,
});
expect(result).toEqual({
key: "vercel-ai-gateway/anthropic/claude-opus-4.6",
ref: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" },
});
});
});
describe("resolveModelRefFromString", () => {

View File

@@ -514,6 +514,7 @@ export function resolveAllowedModelRef(params: {
raw: string;
defaultProvider: string;
defaultModel?: string;
requireProviderOrAlias?: boolean;
}):
| { ref: ModelRef; key: string }
| {
@@ -536,6 +537,9 @@ export function resolveAllowedModelRef(params: {
if (!resolved) {
return { error: `invalid model: ${trimmed}` };
}
if (params.requireProviderOrAlias === true && !resolved.alias && !trimmed.includes("/")) {
return { error: `invalid model: ${trimmed} (use provider/model or alias)` };
}
const status = getModelRefStatus({
cfg: params.cfg,

View File

@@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js";
import { resolveDefaultModelForAgent } from "../../agents/model-selection.js";
import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../../agents/pi-embedded.js";
import { resolveSelectedAndActiveModel } from "../../auto-reply/model-runtime.js";
import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
import { loadConfig } from "../../config/config.js";
@@ -41,7 +43,6 @@ import {
pruneLegacyStoreKeys,
readSessionPreviewItemsFromTranscript,
resolveGatewaySessionStoreTarget,
resolveSessionModelRef,
resolveSessionTranscriptCandidates,
type SessionsPatchResult,
type SessionsPreviewEntry,
@@ -324,15 +325,26 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}
const parsed = parseAgentSessionKey(target.canonicalKey ?? key);
const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
const resolved = resolveSessionModelRef(cfg, applied.entry, agentId);
const agentDefault = resolveDefaultModelForAgent({ cfg, agentId });
const selectedProvider = applied.entry.providerOverride?.trim() || agentDefault.provider;
const selectedModel = applied.entry.modelOverride?.trim() || agentDefault.model;
const selectedActive = resolveSelectedAndActiveModel({
selectedProvider,
selectedModel,
sessionEntry: applied.entry,
});
const result: SessionsPatchResult = {
ok: true,
path: storePath,
key: target.canonicalKey,
entry: applied.entry,
resolved: {
modelProvider: resolved.provider,
model: resolved.model,
modelProvider: selectedActive.active.provider,
model: selectedActive.active.model,
selectedModelProvider: selectedActive.selected.provider,
selectedModel: selectedActive.selected.model,
activeModelProvider: selectedActive.active.provider,
activeModel: selectedActive.active.model,
},
};
respond(true, result, undefined);

View File

@@ -419,6 +419,14 @@ describe("gateway server sessions", () => {
const modelPatched = await rpcReq<{
ok: true;
entry: { modelOverride?: string; providerOverride?: string };
resolved?: {
modelProvider?: string;
model?: string;
selectedModelProvider?: string;
selectedModel?: string;
activeModelProvider?: string;
activeModel?: string;
};
}>(ws, "sessions.patch", {
key: "agent:main:main",
model: "openai/gpt-test-a",
@@ -426,6 +434,10 @@ describe("gateway server sessions", () => {
expect(modelPatched.ok).toBe(true);
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
expect(modelPatched.payload?.resolved?.selectedModelProvider).toBe("openai");
expect(modelPatched.payload?.resolved?.selectedModel).toBe("gpt-test-a");
expect(modelPatched.payload?.resolved?.activeModelProvider).toBe("anthropic");
expect(modelPatched.payload?.resolved?.activeModel).toBe("claude-sonnet-4-6");
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(ws, "sessions.compact", {
key: "agent:main:main",

View File

@@ -846,6 +846,8 @@ export function listSessionsFromStore(params: {
responseUsage: entry?.responseUsage,
modelProvider,
model,
providerOverride: entry?.providerOverride,
modelOverride: entry?.modelOverride,
contextTokens: entry?.contextTokens,
deliveryContext: deliveryFields.deliveryContext,
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,

View File

@@ -37,6 +37,8 @@ export type GatewaySessionRow = {
responseUsage?: "on" | "off" | "tokens" | "full";
modelProvider?: string;
model?: string;
providerOverride?: string;
modelOverride?: string;
contextTokens?: number;
deliveryContext?: DeliveryContext;
lastChannel?: SessionEntry["lastChannel"];
@@ -88,5 +90,9 @@ export type SessionsPatchResult = {
resolved?: {
modelProvider?: string;
model?: string;
selectedModelProvider?: string;
selectedModel?: string;
activeModelProvider?: string;
activeModel?: string;
};
};

View File

@@ -243,6 +243,104 @@ describe("gateway sessions patch", () => {
expect(res.entry.modelOverride).toBe("claude-sonnet-4-6");
});
test("rejects non-alias direct model ids from sessions.patch", async () => {
const store: Record<string, SessionEntry> = {};
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as OpenClawConfig;
const res = await applySessionsPatchToStore({
cfg,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", model: "claude-sonnet-4-6" },
loadGatewayModelCatalog: async () => [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
],
});
expect(res.ok).toBe(false);
if (res.ok) {
return;
}
expect(res.error.message).toContain("invalid model: claude-sonnet-4-6");
});
test("accepts alias refs in strict sessions.patch parsing", async () => {
const store: Record<string, SessionEntry> = {};
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
},
},
},
} as OpenClawConfig;
const res = await applySessionsPatchToStore({
cfg,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", model: "sonnet" },
loadGatewayModelCatalog: async () => [
{ provider: "anthropic", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ provider: "openai", id: "gpt-5.2", name: "gpt-5.2" },
],
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.providerOverride).toBe("anthropic");
expect(res.entry.modelOverride).toBe("claude-sonnet-4-6");
});
test("accepts provider-prefixed refs with slash-containing model ids in strict sessions.patch parsing", async () => {
const store: Record<string, SessionEntry> = {};
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-5.2" },
models: {
"vercel-ai-gateway/anthropic/claude-opus-4.6": { alias: "gateway-opus" },
},
},
},
} as OpenClawConfig;
const res = await applySessionsPatchToStore({
cfg,
store,
storeKey: "agent:main:main",
patch: { key: "agent:main:main", model: "vercel-ai-gateway/anthropic/claude-opus-4.6" },
loadGatewayModelCatalog: async () => [
{
provider: "vercel-ai-gateway",
id: "anthropic/claude-opus-4.6",
name: "Gateway Claude Opus 4.6",
},
],
});
expect(res.ok).toBe(true);
if (!res.ok) {
return;
}
expect(res.entry.providerOverride).toBe("vercel-ai-gateway");
expect(res.entry.modelOverride).toBe("anthropic/claude-opus-4.6");
});
test("accepts explicit allowlisted refs absent from bundled catalog", async () => {
const store: Record<string, SessionEntry> = {};
const cfg = {

View File

@@ -304,6 +304,7 @@ export async function applySessionsPatchToStore(params: {
raw: trimmed,
defaultProvider: resolvedDefault.provider,
defaultModel: subagentModelHint ?? resolvedDefault.model,
requireProviderOrAlias: true,
});
if ("error" in resolved) {
return invalid(resolved.error);

View File

@@ -67,6 +67,8 @@ export type GatewaySessionList = {
updatedAt?: number | null;
sendPolicy?: string;
responseUsage?: ResponseUsageMode;
providerOverride?: string;
modelOverride?: string;
label?: string;
provider?: string;
groupChannel?: string;

View File

@@ -111,4 +111,92 @@ describe("tui session actions", () => {
expect(updateFooter).toHaveBeenCalledTimes(2);
expect(requestRender).toHaveBeenCalledTimes(2);
});
it("keeps selected model visible across refresh when runtime model still lags", async () => {
const listSessions = vi.fn().mockResolvedValue({
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: {
modelProvider: "anthropic",
model: "claude-opus-4-6",
},
sessions: [
{
key: "agent:main:main",
updatedAt: 10,
modelProvider: "anthropic",
model: "claude-opus-4-6",
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-5",
},
],
});
const state: TuiStateAccess = {
agentDefaultId: "main",
sessionMainKey: "agent:main:main",
sessionScope: "global",
agents: [],
currentAgentId: "main",
currentSessionKey: "agent:main:main",
currentSessionId: null,
activeChatRunId: null,
historyLoaded: false,
sessionInfo: {},
initialSessionApplied: true,
isConnected: true,
autoMessageSent: false,
toolsExpanded: false,
showThinking: false,
connectionStatus: "connected",
activityStatus: "idle",
statusTimeout: null,
lastCtrlCAt: 0,
};
const { applySessionInfoFromPatch, refreshSessionInfo } = createSessionActions({
client: { listSessions } as unknown as GatewayChatClient,
chatLog: { addSystem: vi.fn() } as unknown as import("./components/chat-log.js").ChatLog,
tui: { requestRender: vi.fn() } as unknown as import("@mariozechner/pi-tui").TUI,
opts: {},
state,
agentNames: new Map(),
initialSessionInput: "",
initialSessionAgentId: null,
resolveSessionKey: vi.fn(),
updateHeader: vi.fn(),
updateFooter: vi.fn(),
updateAutocompleteProvider: vi.fn(),
setActivityStatus: vi.fn(),
});
applySessionInfoFromPatch({
ok: true,
path: "/tmp/sessions.json",
key: "agent:main:main",
entry: {
sessionId: "sess-main",
updatedAt: 10,
modelProvider: "anthropic",
model: "claude-opus-4-6",
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-5",
},
resolved: {
modelProvider: "anthropic",
model: "claude-opus-4-6",
selectedModelProvider: "anthropic",
selectedModel: "claude-sonnet-4-5",
activeModelProvider: "anthropic",
activeModel: "claude-opus-4-6",
},
});
expect(state.sessionInfo.modelProvider).toBe("anthropic");
expect(state.sessionInfo.model).toBe("claude-sonnet-4-5");
await refreshSessionInfo();
expect(state.sessionInfo.modelProvider).toBe("anthropic");
expect(state.sessionInfo.model).toBe("claude-sonnet-4-5");
});
});

View File

@@ -115,17 +115,18 @@ export function createSessionActions(context: SessionActionContext) {
};
const resolveModelSelection = (entry?: SessionInfoEntry) => {
const overrideModel = entry?.modelOverride?.trim();
if (overrideModel) {
const overrideProvider =
entry?.providerOverride?.trim() || entry?.modelProvider || state.sessionInfo.modelProvider;
return { modelProvider: overrideProvider, model: overrideModel };
}
if (entry?.modelProvider || entry?.model) {
return {
modelProvider: entry.modelProvider ?? state.sessionInfo.modelProvider,
model: entry.model ?? state.sessionInfo.model,
};
}
const overrideModel = entry?.modelOverride?.trim();
if (overrideModel) {
const overrideProvider = entry?.providerOverride?.trim() || state.sessionInfo.modelProvider;
return { modelProvider: overrideProvider, model: overrideModel };
}
return {
modelProvider: state.sessionInfo.modelProvider,
model: state.sessionInfo.model,
@@ -270,14 +271,21 @@ export function createSessionActions(context: SessionActionContext) {
updateHeader();
}
const resolved = result.resolved;
const entry =
resolved && (resolved.modelProvider || resolved.model)
const hasSelected = Boolean(resolved?.selectedModelProvider || resolved?.selectedModel);
const entry: SessionInfoEntry = {
...result.entry,
...(hasSelected
? {
...result.entry,
modelProvider: resolved.modelProvider ?? result.entry.modelProvider,
model: resolved.model ?? result.entry.model,
modelProvider:
resolved?.selectedModelProvider ??
result.entry.providerOverride ??
result.entry.modelProvider,
model: resolved?.selectedModel ?? result.entry.modelOverride ?? result.entry.model,
providerOverride: resolved?.selectedModelProvider ?? result.entry.providerOverride,
modelOverride: resolved?.selectedModel ?? result.entry.modelOverride,
}
: result.entry;
: {}),
};
applySessionInfo({ entry, force: true });
};