fix(ui): effort slider matches selected level after model switches (#122125)

* fix(ui): align effort picker after model switches

Publish ordered per-model effort profiles from the Gateway and reconcile new-session effort overrides so labels, slider positions, and create payloads stay consistent.

* fix(ci): align model profile contracts
This commit is contained in:
Peter Steinberger
2026-08-11 13:14:11 -07:00
committed by GitHub
parent 32a43960c6
commit 627e83d4f7
37 changed files with 530 additions and 245 deletions
@@ -12907,6 +12907,8 @@ public struct ModelChoice: Codable, Sendable {
public let available: Bool?
public let contextwindow: Int?
public let reasoning: Bool?
public let thinkinglevels: [[String: AnyCodable]]?
public let thinkingdefault: String?
public let supportstools: Bool?
public let agentruntime: [String: AnyCodable]?
public let apikeysupported: Bool?
@@ -12920,6 +12922,8 @@ public struct ModelChoice: Codable, Sendable {
available: Bool? = nil,
contextwindow: Int? = nil,
reasoning: Bool? = nil,
thinkinglevels: [[String: AnyCodable]]? = nil,
thinkingdefault: String? = nil,
supportstools: Bool? = nil,
agentruntime: [String: AnyCodable]? = nil,
apikeysupported: Bool? = nil,
@@ -12932,6 +12936,8 @@ public struct ModelChoice: Codable, Sendable {
self.available = available
self.contextwindow = contextwindow
self.reasoning = reasoning
self.thinkinglevels = thinkinglevels
self.thinkingdefault = thinkingdefault
self.supportstools = supportstools
self.agentruntime = agentruntime
self.apikeysupported = apikeysupported
@@ -12946,6 +12952,8 @@ public struct ModelChoice: Codable, Sendable {
case available
case contextwindow = "contextWindow"
case reasoning
case thinkinglevels = "thinkingLevels"
case thinkingdefault = "thinkingDefault"
case supportstools = "supportsTools"
case agentruntime = "agentRuntime"
case apikeysupported = "apiKeySupported"
@@ -1 +1 @@
{"contentHash":"8c91a4aac45efb318043e133624a8334599c7f0f73c7ba7212dbb661f88c3b17","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"8f8189612c59f0a57b65ebd310af811f64953e4b45271c4c62902f3fcfbd8537","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"cea38cb2354e456c9dcb62fcb59d4c5a1850eb7b35cf2c6dc1e6b4052cb239f2","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"b8a161ee64194146ccfadc417f27d39911eea97ee818538df86653d1a9f414cc","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"be5e5a878566af7ed7371bcc8bf6501771b7366c019e2e8132b1bd3d598b7b48","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"77f828ec162318163cf147fc99143fcafaf35cfd2e2654559fd14bd778ae43d9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"f4a908c9a10596f9aec9e9ebfda956d87e7d38d7a66b98dd36dca655ab0df554","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"1d756f82df06f6ce77d5b4b1e3abfad6046ff49fed7641b3da206b60feb91404","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"3642828e0c335f3412a575e04ac5179c82226386942fcaecaf6ecf229cefb246","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"fe0b763f911c6f67d25d63b6fd0defe104bef798008f830ae0f6dbd013a79ff6","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"b976e3217681b57aab8ecab4a94ecf0f8238fe4c68de6c7bb9e2588e9c726622","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"d8f4fbf15031d5731596db8f48d15d28c2522e6d563ab0bd7fa41f4908c53314","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"b65bc3c8f77d9ae5fcfe03fe596f2cd948ce04a2544fd5df609c172ec3d4d267","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"386c6006608513ffa9c458b1ab085c5b3ca3df953b8cd09cdc17846b8f616aa6","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
@@ -1 +1 @@
{"contentHash":"e17ce7a27cdf887381927cc2e39300105c26f72fccd34da5fead687237f022be","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"}
{"contentHash":"2c86a1a976f82d74069801758624315dc46020a418f06d565f6f6d0a74bb7cd1","entrypoint":"command-auth-native","importSpecifier":"openclaw/plugin-sdk/command-auth-native"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d22bf2e5ae14ee5ec01ea825c2bc16c908a15f78dd462c3e160393aa871d7cf5","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"a501e9c9f95cda7027f83d52c7b4b95153531cc24540f0e6803435e75def6153","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"66c63de1253ea9f27ada35301d7f9a53504483c10ca5dd281fe50b645b5bccd2","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"ceca2545902bcea34a836763afef76c9b55bd0fa639cceb89b7122c0f7c6027a","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"e6d85aba0cc65a4acbfb40ebcb26efda2cc7510f646141180d50cec22c07f1c0","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"d83a8d16c27fdc15ee35827799d7a245b2b46e6a5b8b98b1a1b623d466a1dbd4","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"c1226a9266ddddfd3cb3ec3842a3eb43d7b05738ff495e18b9a13654d8bbc3e7","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"3e853390cc6efef245c9c220512fca49d2f8812abf1968e302c48e48b223d633","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
@@ -1 +1 @@
{"contentHash":"538b128a5de8b38c9c3e13cdbb74d0eaa0e6ce7a8066c5852204b77b37536734","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"35904f50b6b6826386c774052cf3ab38a07a5ccabbd264237c1ce0bd5f1275c8","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"9e50d560be0fae99edf2ab5400625033c3515d00b16113b085543d6f218c17cd","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"3e7a63e4f1d24dd6a2af9b7c5fd47837781471f5d92cdc1ba15f079259617402","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"9748f103ca50929f6a67e77c6708c0fb1a24e5d83ec1ae9e15485a80fdc20398","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"7f5d4e9c2f0593f433cb06c9acbca4c7a683e7d097d8f11304450076a7deb6e7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"4c5ae6de0b18a8ad16c4c6f8437bb18c0d2e8f511452ac47ef1442136c7f90a9","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"43d1e66a594b093ebd63d48de2a17900eef743404ef10068735a2aa8f2900f9f","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"a204b23b49393b23755ed3f6d5f44c14248abba916063c6a4f363d39a019c800","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"cc7dd850ee1af91876e83827b5087c1374328f8eb7b9d559c5df7e34695663e2","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"ed3e64c4f91e222c62209dbb7ae7632ff3ea85ae5658264b559d36997f037b7b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"6c157ca687f5bf384fcf526ca2465f85b27e3bedba0bde3c8ecf1e4060ddcd5d","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
@@ -219,6 +219,11 @@ describe("ModelsListResultSchema", () => {
name: "GPT Image",
provider: "openai",
agentRuntime: { id: "codex", fallback: "openclaw", source: "model" },
thinkingLevels: [
{ id: "off", label: "Off" },
{ id: "xhigh", label: "Extra high" },
],
thinkingDefault: "xhigh",
input: ["text", "image", "audio", "video", "document"],
};
@@ -241,6 +246,7 @@ describe("ModelsListResultSchema", () => {
{
models: [{ ...model, agentRuntime: { id: "codex", source: "unknown" } }],
},
{ models: [{ ...model, thinkingLevels: [{ id: "", label: "Off" }] }] },
{ models: [{ ...model, input: ["text", "binary"] }] },
{ models: [], providerOutcomes: [{ provider: "openai", status: "unknown" }] },
{
@@ -29,6 +29,11 @@ const GatewayAgentRuntimeSchema = closedObject({
]),
});
const GatewayThinkingLevelOptionSchema = closedObject({
id: NonEmptyString,
label: NonEmptyString,
});
export const ModelChoiceSchema = closedObject({
id: NonEmptyString,
name: NonEmptyString,
@@ -37,6 +42,8 @@ export const ModelChoiceSchema = closedObject({
available: Type.Optional(Type.Boolean()),
contextWindow: Type.Optional(Type.Integer({ minimum: 1 })),
reasoning: Type.Optional(Type.Boolean()),
thinkingLevels: Type.Optional(Type.Array(GatewayThinkingLevelOptionSchema)),
thinkingDefault: Type.Optional(NonEmptyString),
supportsTools: Type.Optional(Type.Boolean()),
agentRuntime: Type.Optional(GatewayAgentRuntimeSchema),
apiKeySupported: Type.Optional(Type.Boolean()),
@@ -79,14 +86,7 @@ export const AgentSummarySchema = closedObject({
}),
),
agentRuntime: Type.Optional(GatewayAgentRuntimeSchema),
thinkingLevels: Type.Optional(
Type.Array(
closedObject({
id: NonEmptyString,
label: NonEmptyString,
}),
),
),
thinkingLevels: Type.Optional(Type.Array(GatewayThinkingLevelOptionSchema)),
thinkingOptions: Type.Optional(Type.Array(NonEmptyString)),
thinkingDefault: Type.Optional(NonEmptyString),
});
+4
View File
@@ -49,6 +49,8 @@ export function resolveEffectiveAgentRuntime(params: {
cfg: OpenClawConfig;
provider: string;
modelId: string;
modelApi?: string | null;
modelBaseUrl?: unknown;
agentId?: string;
sessionKey?: string;
sessionEntry?: Pick<SessionEntry, "agentHarnessId" | "agentRuntimeOverride">;
@@ -63,6 +65,8 @@ export function resolveEffectiveAgentRuntime(params: {
resolveAgentHarnessPolicy({
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
modelBaseUrl: params.modelBaseUrl,
config: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
@@ -571,7 +571,7 @@ describe("models.list OpenAI routes", () => {
await withEnvAsync({ ...WITHOUT_OPENAI_ENV_AUTH, OPENAI_API_KEY: "test-key" }, async () => {
await expect(listModels({ catalog: [row], cfg })).resolves.toEqual({
models: [
{
expect.objectContaining({
id: "gpt-5.4-nano",
name: "GPT-5.4 Nano",
provider: "openai",
@@ -579,7 +579,7 @@ describe("models.list OpenAI routes", () => {
contextWindow: 1_000_000,
reasoning: true,
available: true,
},
}),
],
});
});
@@ -683,13 +683,13 @@ describe("models.list OpenAI routes", () => {
await expect(listModels({ catalog: [row], cfg })).resolves.toEqual({
models: [
{
expect.objectContaining({
id: "gpt-5.5",
name: "gpt-5.5",
provider: "openai",
agentRuntime: IMPLICIT_CODEX_RUNTIME,
available: true,
},
}),
],
});
@@ -704,7 +704,7 @@ describe("models.list OpenAI routes", () => {
} as ModelCatalogEntry;
const subscriptionProjection = {
models: [
{
expect.objectContaining({
id: "gpt-5.5",
name: "gpt-5.5",
provider: "openai",
@@ -712,7 +712,7 @@ describe("models.list OpenAI routes", () => {
contextWindow: 400_000,
reasoning: true,
available: true,
},
}),
],
};
await expect(listModels({ catalog: [row, chatGPTRow], cfg })).resolves.toEqual(
@@ -743,7 +743,7 @@ describe("models.list OpenAI routes", () => {
}),
).resolves.toEqual({
models: [
{
expect.objectContaining({
id: "gpt-5.5",
name: "GPT-5.5",
provider: "openai",
@@ -752,7 +752,7 @@ describe("models.list OpenAI routes", () => {
reasoning: true,
input: ["text", "video"],
available: true,
},
}),
],
});
@@ -765,7 +765,7 @@ describe("models.list OpenAI routes", () => {
} as unknown as OpenClawConfig;
await expect(listModels({ catalog: [row], cfg: apiKeyFirst })).resolves.toEqual({
models: [
{
expect.objectContaining({
id: "gpt-5.5",
name: "gpt-5.5",
provider: "openai",
@@ -773,7 +773,7 @@ describe("models.list OpenAI routes", () => {
contextWindow: 1_000_000,
reasoning: true,
available: true,
},
}),
],
});
},
@@ -2,6 +2,7 @@
// strips runtime-only provider params before sending the browse API payload.
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { asPositiveSafeInteger as resolvePositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import type { ModelChoice } from "../../../packages/gateway-protocol/src/schema/agents-models-skills.js";
import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credentials.js";
import {
resolveAgentEffectiveModelPrimary,
@@ -55,18 +56,15 @@ import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth-
import type { ProviderCatalogOutcome } from "../../plugins/provider-catalog.types.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import type { GatewayAgentRuntime } from "../../shared/session-types.js";
import { resolveGatewayModelThinkingProfile } from "../session-utils-model.js";
import { createModelsListAuthResolver } from "./models-list-auth-resolver.js";
import type { GatewayRequestContext } from "./types.js";
type ModelsListView = ModelCatalogBrowseView;
type ModelsListEntry = Pick<
ModelCatalogEntry,
ModelChoice,
"alias" | "contextWindow" | "id" | "input" | "name" | "provider" | "reasoning"
> & { available?: boolean; supportsTools?: boolean };
type ModelsListEntryWithCapabilities = ModelsListEntry & {
agentRuntime?: GatewayAgentRuntime;
apiKeySupported?: boolean;
};
type ModelsListEntryWithCapabilities = ModelChoice;
type ApiKeyProviderCapabilities = {
providers: ReadonlyMap<string, boolean>;
resolveProvider(provider: string): string;
@@ -82,7 +80,7 @@ let loggedSlowModelsListCatalog = false;
// Unknown views are rejected by protocol validation first; this helper keeps the
// handler default explicit for older clients that omit the field.
function resolveModelsListView(params: Record<string, unknown>): ModelsListView {
function resolveModelsListView(params: Record<string, unknown>): ModelCatalogBrowseView {
const view = params.view;
return view === "configured" || view === "provider-config" || view === "all" ? view : "default";
}
@@ -425,10 +423,9 @@ async function buildPublicModelsListEntries(params: {
preserveUnknownAvailability?: boolean;
apiKeyCapabilities?: ApiKeyProviderCapabilities;
}): Promise<ModelsListEntryWithCapabilities[]> {
return await Promise.all(
return Promise.all(
params.catalog.map(async (entry): Promise<ModelsListEntryWithCapabilities> => {
const evaluation = await params.evaluateEntry(entry);
const publicEntry = buildPublicModelProjection(entry);
const syntheticLocalAvailable =
evaluation.availability === undefined &&
evaluation.routeResolution === null &&
@@ -443,9 +440,23 @@ async function buildPublicModelsListEntries(params: {
agentId: params.agentId,
entry,
});
const thinkingProfile =
typeof entry.reasoning === "boolean"
? resolveGatewayModelThinkingProfile({
cfg: params.cfg,
agentId: params.agentId,
provider: entry.provider,
model: entry.id,
modelCatalog: params.catalog,
})
: undefined;
return {
...publicEntry,
...buildPublicModelProjection(entry),
...(agentRuntime ? { agentRuntime } : {}),
...(thinkingProfile && {
thinkingLevels: thinkingProfile.levels,
thinkingDefault: thinkingProfile.defaultLevel,
}),
...(capabilityProvider && params.apiKeyCapabilities?.providers.has(capabilityProvider)
? {
apiKeySupported: params.apiKeyCapabilities.providers.get(capabilityProvider) === true,
+49
View File
@@ -320,6 +320,14 @@ describe("models.list", () => {
reasoning: true,
input: ["text", "image"],
available: true,
thinkingLevels: [
{ id: "off", label: "off" },
{ id: "minimal", label: "minimal" },
{ id: "low", label: "low" },
{ id: "medium", label: "medium" },
{ id: "high", label: "high" },
],
thinkingDefault: "medium",
},
],
},
@@ -1585,6 +1593,47 @@ describe("models.list", () => {
);
});
it("projects ordered thinking profiles without exposing raw compatibility metadata", async () => {
const { request, respond } = requestModelsList({
view: "all",
loadGatewayModelCatalog: vi.fn(() =>
Promise.resolve([
{
id: "reasoning-model",
name: "Reasoning Model",
provider: "demo-provider",
reasoning: true,
compat: {
supportedReasoningEfforts: ["max", "xhigh"],
privateRouteHint: "do-not-publish",
},
},
]),
),
reqId: "req-models-list-thinking-profile",
});
await request;
const payload = respond.mock.calls[0]?.[1] as { models: Array<Record<string, unknown>> };
expect(payload.models).toEqual([
expect.objectContaining({
id: "reasoning-model",
thinkingLevels: [
{ id: "off", label: "off" },
{ id: "minimal", label: "minimal" },
{ id: "low", label: "low" },
{ id: "medium", label: "medium" },
{ id: "high", label: "high" },
{ id: "xhigh", label: "xhigh" },
{ id: "max", label: "max" },
{ id: "ultra", label: "ultra" },
],
thinkingDefault: "medium",
}),
]);
expect(payload.models[0]).not.toHaveProperty("compat");
});
it("does not reinterpret context tokens or expose model input metadata", async () => {
const { request, respond } = requestModelsList({
view: "all",
@@ -1452,7 +1452,7 @@ describe("gateway server chat", () => {
}
| undefined;
expect(payload?.metadata?.models).toEqual([
{
expect.objectContaining({
id: "gpt-5.5",
name: "GPT-5.5",
provider: "openai",
@@ -1460,7 +1460,7 @@ describe("gateway server chat", () => {
contextWindow: 400_000,
reasoning: false,
available: true,
},
}),
]);
expect(payload?.sessionInfo?.thinkingLevels?.map((level) => level.id)).toEqual(["off"]);
expect(payload?.defaults?.thinkingLevels?.map((level) => level.id)).toEqual(["off"]);
+41 -26
View File
@@ -76,7 +76,7 @@ function resolveGatewaySessionThinkingLevel(params: {
});
}
export function resolveGatewaySessionThinkingDefault(params: {
function resolveGatewaySessionThinkingDefault(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
@@ -105,25 +105,43 @@ export function resolveGatewaySessionThinkingDefault(params: {
});
}
function resolveSessionRowThinkingMetadata(params: {
export function resolveGatewayModelThinkingProfile(params: {
cfg: OpenClawConfig;
agentId: string;
provider: string;
model: string;
agentRuntime: string;
agentRuntime?: string;
modelCatalog?: ModelCatalogEntry[];
rowContext?: SessionListRowContext;
sessionKey?: string;
}): {
levels: ReturnType<typeof listThinkingLevelOptions>;
defaultLevel: ReturnType<typeof resolveGatewaySessionThinkingDefault>;
} {
const catalogEntry = params.modelCatalog
? findModelCatalogEntry(params.modelCatalog, {
provider: params.provider,
modelId: params.model,
})
: undefined;
const agentRuntime =
params.agentRuntime ??
resolveEffectiveAgentRuntime({
cfg: params.cfg,
provider: params.provider,
modelId: params.model,
modelApi: catalogEntry?.api,
modelBaseUrl: catalogEntry?.baseUrl,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!params.rowContext) {
return {
levels: listThinkingLevelOptions(
params.provider,
params.model,
params.modelCatalog,
params.agentRuntime,
agentRuntime,
),
defaultLevel: resolveGatewaySessionThinkingDefault({
cfg: params.cfg,
@@ -131,11 +149,11 @@ function resolveSessionRowThinkingMetadata(params: {
model: params.model,
agentId: params.agentId,
modelCatalog: params.modelCatalog,
agentRuntime: params.agentRuntime,
agentRuntime,
}),
};
}
const key = `${normalizeAgentId(params.agentId)}\0${params.agentRuntime}\0${createSessionRowModelCacheKey(
const key = `${normalizeAgentId(params.agentId)}\0${agentRuntime}\0${createSessionRowModelCacheKey(
params.provider,
params.model,
)}`;
@@ -148,7 +166,7 @@ function resolveSessionRowThinkingMetadata(params: {
params.provider,
params.model,
params.modelCatalog,
params.agentRuntime,
agentRuntime,
),
defaultLevel: resolveGatewaySessionThinkingDefault({
cfg: params.cfg,
@@ -156,7 +174,7 @@ function resolveSessionRowThinkingMetadata(params: {
model: params.model,
agentId: params.agentId,
modelCatalog: params.modelCatalog,
agentRuntime: params.agentRuntime,
agentRuntime,
}),
};
params.rowContext.thinkingMetadataByModelRef.set(key, metadata);
@@ -206,17 +224,25 @@ export function resolveGatewaySessionThinkingProjectionInternal(
id: persistedAgentRuntime,
source: persistedAgentRuntimeSource,
};
const catalogEntry = params.modelCatalog
? findModelCatalogEntry(params.modelCatalog, {
provider: params.provider,
modelId: params.model,
})
: undefined;
const thinkingRuntime = acpMeta
? concretizeAgentRuntime(acpMeta.backend ?? agentRuntime.id)
: resolveEffectiveAgentRuntime({
cfg: params.cfg,
provider: params.provider,
modelId: params.model,
modelApi: catalogEntry?.api,
modelBaseUrl: catalogEntry?.baseUrl,
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionEntry: params.entry,
});
const metadata = resolveSessionRowThinkingMetadata({
const metadata = resolveGatewayModelThinkingProfile({
cfg: params.cfg,
agentId: params.agentId,
provider: params.provider,
@@ -270,33 +296,22 @@ export function getSessionDefaults(
sessionKey,
acpRuntime: false,
});
const thinkingRuntime = resolveEffectiveAgentRuntime({
const thinkingProfile = resolveGatewayModelThinkingProfile({
cfg,
provider: resolved.provider,
modelId: resolved.model,
model: resolved.model,
agentId,
modelCatalog,
sessionKey,
});
const thinkingLevels = listThinkingLevelOptions(
resolved.provider,
resolved.model,
modelCatalog,
thinkingRuntime,
);
return {
modelProvider: resolved.provider ?? null,
model: resolved.model ?? null,
contextTokens: contextTokens ?? null,
agentRuntime,
thinkingLevels,
thinkingOptions: thinkingLevels.map((level) => level.label),
thinkingDefault: resolveGatewaySessionThinkingDefault({
cfg,
provider: resolved.provider,
model: resolved.model,
modelCatalog,
agentRuntime: thinkingRuntime,
}),
thinkingLevels: thinkingProfile.levels,
thinkingOptions: thinkingProfile.levels.map((level) => level.label),
thinkingDefault: thinkingProfile.defaultLevel,
};
}
+9 -23
View File
@@ -19,9 +19,7 @@ import { resolveAgentAvatarUrlFromSource } from "../agents/identity-avatar-file.
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js";
import { insideGitCheckout } from "../agents/worktrees/git.js";
import { listThinkingLevelOptions } from "../auto-reply/thinking.js";
import { getRuntimeConfig } from "../config/io.js";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import {
@@ -34,7 +32,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { isAcpSessionKey } from "../sessions/session-key-utils.js";
import { listGatewayAgentsBasic } from "./agent-list.js";
import { resolveGatewaySessionThinkingDefault } from "./session-utils-model.js";
import { resolveGatewayModelThinkingProfile } from "./session-utils-model.js";
import {
resolveGatewaySessionStoreTarget,
resolveGatewaySessionStoreTargetWithStore,
@@ -344,20 +342,15 @@ export function listAgentsForGateway(
sessionKey,
acpRuntime: false,
});
const thinkingRuntime = resolveEffectiveAgentRuntime({
const agentModelCatalog = options?.modelCatalogByAgentId?.get(id) ?? modelCatalog;
const thinkingProfile = resolveGatewayModelThinkingProfile({
cfg,
provider: resolvedModel.provider,
modelId: resolvedModel.model,
agentId: id,
provider: resolvedModel.provider,
model: resolvedModel.model,
modelCatalog: agentModelCatalog,
sessionKey,
});
const agentModelCatalog = options?.modelCatalogByAgentId?.get(id) ?? modelCatalog;
const thinkingLevels = listThinkingLevelOptions(
resolvedModel.provider,
resolvedModel.model,
agentModelCatalog,
thinkingRuntime,
);
const workspace = resolveAgentWorkspaceDir(cfg, id);
// Must mirror the sessions.create worktree preflight: subdirectory workspaces inside a
// repo are worktree-capable, so the UI toggle and the create path cannot diverge.
@@ -371,16 +364,9 @@ export function listAgentsForGateway(
workspace,
workspaceGit,
agentRuntime,
thinkingLevels,
thinkingOptions: thinkingLevels.map((level) => level.label),
thinkingDefault: resolveGatewaySessionThinkingDefault({
cfg,
provider: resolvedModel.provider,
model: resolvedModel.model,
agentId: id,
modelCatalog: agentModelCatalog,
agentRuntime: thinkingRuntime,
}),
thinkingLevels: thinkingProfile.levels,
thinkingOptions: thinkingProfile.levels.map((level) => level.label),
thinkingDefault: thinkingProfile.defaultLevel,
},
model ? { model } : {},
);
+2
View File
@@ -815,6 +815,8 @@ export type ModelCatalogEntry = {
available?: boolean;
contextWindow?: number;
reasoning?: boolean;
thinkingLevels?: GatewayThinkingLevelOption[];
thinkingDefault?: string;
supportsTools?: boolean;
agentRuntime?: import("../../../packages/gateway-protocol/src/schema.js").GatewayAgentRuntime;
input?: Array<"text" | "image" | "document">;
@@ -230,6 +230,100 @@ suite.define(() => {
});
});
it("keeps the effort label, slider stop, and create payload aligned after a model switch", async () => {
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const levels = (ids: string[]) => ids.map((id) => ({ id, label: id }));
const kimiLevels = levels([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
]);
const gateway = await installMockGateway(page, {
agentModel: "kimi/k3",
models: [
{
id: "k3",
name: "Kimi K3",
provider: "kimi",
reasoning: true,
thinkingLevels: kimiLevels,
thinkingDefault: "high",
},
{
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
provider: "openai",
reasoning: true,
thinkingLevels: levels(["off", "minimal", "low", "medium", "high", "xhigh", "max"]),
thinkingDefault: "medium",
},
],
methodResponses: {
"agents.list": {
...mainAgentList(),
agents: [
{
id: "main",
name: "Main",
identity: { name: "Main" },
model: { primary: "kimi/k3" },
thinkingLevels: kimiLevels,
thinkingDefault: "high",
},
],
},
"sessions.create": { key: "agent:main:thinking-model-switch", runStarted: true },
},
});
await page.goto(`${suite.server.baseUrl}new`);
const effortSelect = page.locator('[data-chat-thinking-select="true"]');
await effortSelect.click();
const thinkingSlider = page.locator('[data-chat-thinking-slider="true"]');
await thinkingSlider.evaluate((element) => {
const input = element as HTMLInputElement;
input.value = "5";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await expect.poll(() => effortSelect.getAttribute("data-chat-thinking-value")).toBe("xhigh");
const modelSelect = page.locator('[data-chat-model-select="true"]');
await modelSelect.click();
await page.locator('[data-chat-model-option="openai/gpt-5.6-sol"]').click();
await effortSelect.click();
await expect
.poll(() => thinkingSlider.getAttribute("data-chat-thinking-values"))
.toBe("off,minimal,low,medium,high,xhigh,max");
await expect.poll(() => thinkingSlider.inputValue()).toBe("5");
expect(await thinkingSlider.getAttribute("max")).toBe("6");
expect(await thinkingSlider.getAttribute("aria-valuetext")).toBe("Extra high");
expect(
Number.parseFloat(
await thinkingSlider.evaluate((element) =>
(element as HTMLElement).style.getPropertyValue("--reasoning-fill"),
),
),
).toBeCloseTo(83.33, 1);
await effortSelect.click();
await page.locator(".new-session-page__message").fill("keep the selected effort");
await page.getByRole("button", { name: "Start session" }).click();
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
message: "keep the selected effort",
model: "openai/gpt-5.6-sol",
thinkingLevel: "xhigh",
});
});
});
it("restores valid preferences and repairs a worktree rejected by workspace metadata", async () => {
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
const gateway = await installMockGateway(page, {
+1 -1
View File
@@ -26,7 +26,7 @@ type ChatModelSelectStateInput = {
sessionsResult: SessionsListResult | null;
};
export type ChatModelSelectOption = {
type ChatModelSelectOption = {
value: string;
label: string;
};
+8 -3
View File
@@ -51,7 +51,12 @@ describe("chat thinking helpers", () => {
},
});
expect(state.currentOverride).toBe("ultra");
expect(state.selection).toEqual({
kind: "unanchored",
source: "override",
value: "ultra",
displayLabel: "Ultra",
});
expect(state.options.map((option) => option.value)).toEqual(["max"]);
});
@@ -102,14 +107,14 @@ describe("chat thinking helpers", () => {
thinkingDefault: "medium",
};
const inherited = resolveChatThinkingSelectState({
catalog: [{ provider: "openai", id: "gpt-5.6-sol", reasoning: true }],
catalog: [{ provider: "openai", id: "gpt-5.6-sol", name: "GPT-5.6 Sol", reasoning: true }],
defaults,
sessionKey: "new-session:main",
session: { key: "new-session:main", kind: "direct", updatedAt: null },
sessionsResult: null,
});
const explicit = resolveChatThinkingSelectState({
catalog: [{ provider: "openai", id: "gpt-5.6-sol", reasoning: true }],
catalog: [{ provider: "openai", id: "gpt-5.6-sol", name: "GPT-5.6 Sol", reasoning: true }],
defaults,
sessionKey: "new-session:main",
session: {
+55 -31
View File
@@ -2,7 +2,6 @@ import {
BASE_THINKING_LEVELS,
normalizeThinkLevel,
resolveThinkingDefaultForModelCore,
type ThinkingCatalogEntry,
} from "../../../../src/auto-reply/thinking.shared.js";
// Control UI module implements thinking behavior.
import type {
@@ -15,21 +14,26 @@ import { pushUniqueTrimmedSelectOption } from "../select-options.ts";
import { sessionModelMatchesDefaults } from "../session-model-defaults.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
function listThinkingLevelLabels(
provider?: string | null,
model?: string | null,
): readonly string[] {
void provider;
void model;
return BASE_THINKING_LEVELS;
}
type ThinkingSessionDefaults = SessionsListResult["defaults"] | undefined;
type ChatThinkingSelectState = {
currentOverride: string;
defaultLabel: string;
defaultValue: string;
type ChatThinkingSelection =
| {
kind: "anchored";
source: "override" | "default";
value: string;
displayLabel: string;
index: number;
}
| {
kind: "unanchored";
source: "override" | "default";
value: string;
displayLabel: string;
};
export type ChatThinkingSelectState = {
selection: ChatThinkingSelection;
inherited: { value: string; displayLabel: string };
options: Array<{ value: string; label: string }>;
};
@@ -147,8 +151,18 @@ function resolveThinkingTargetModel(params: {
};
}
function resolveThinkingCatalogEntry(
catalog: readonly ModelCatalogEntry[],
provider: string | null,
model: string | null,
): ModelCatalogEntry | undefined {
return provider && model
? catalog.find((entry) => entry.provider === provider && entry.id === model)
: undefined;
}
function resolveThinkingLevelOptions(params: {
catalog: readonly ThinkingCatalogEntry[];
catalog: readonly ModelCatalogEntry[];
defaults: ThinkingSessionDefaults;
hideUnsupportedOffOnly?: boolean;
model: string | null;
@@ -156,14 +170,12 @@ function resolveThinkingLevelOptions(params: {
session: GatewaySessionRow | undefined;
}): GatewayThinkingLevelOption[] {
const modelMatchesDefaults = sessionModelMatchesDefaults(params.session, params.defaults);
const catalogEntry =
params.provider && params.model
? params.catalog.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
)
: undefined;
const catalogEntry = resolveThinkingCatalogEntry(params.catalog, params.provider, params.model);
const explicitLevels =
(params.session?.thinkingLevels?.length ? params.session.thinkingLevels : null) ??
(params.session?.model && catalogEntry?.thinkingLevels?.length
? catalogEntry.thinkingLevels
: null) ??
(modelMatchesDefaults && params.defaults?.thinkingLevels?.length
? params.defaults.thinkingLevels
: null);
@@ -187,11 +199,7 @@ function resolveThinkingLevelOptions(params: {
return [];
}
}
const labels =
explicitLabels ??
(params.provider && params.model
? listThinkingLevelLabels(params.provider, params.model)
: listThinkingLevelLabels());
const labels = explicitLabels ?? BASE_THINKING_LEVELS;
return labels.map((label) => ({
id: normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label),
label,
@@ -199,7 +207,7 @@ function resolveThinkingLevelOptions(params: {
}
export function resolveChatThinkingSelectState(params: {
catalog: readonly ThinkingCatalogEntry[];
catalog: readonly ModelCatalogEntry[];
defaults?: SessionsListResult["defaults"];
session?: GatewaySessionRow;
sessionKey: string;
@@ -214,6 +222,7 @@ export function resolveChatThinkingSelectState(params: {
: "";
const defaults = params.defaults ?? params.sessionsResult?.defaults;
const { provider, model } = resolveThinkingTargetModel({ defaults, session });
const catalogEntry = resolveThinkingCatalogEntry(params.catalog, provider, model);
const levels = resolveThinkingLevelOptions({
catalog: params.catalog,
defaults,
@@ -228,6 +237,7 @@ export function resolveChatThinkingSelectState(params: {
: undefined;
const defaultLevel =
session?.thinkingDefault ??
(session?.model ? catalogEntry?.thinkingDefault : undefined) ??
defaultFromSessionDefaults ??
(provider && model
? resolveThinkingDefaultForModelCore({
@@ -237,11 +247,25 @@ export function resolveChatThinkingSelectState(params: {
})
: "off");
const effectiveOverride = levels.length === 0 && currentOverride === "off" ? "" : currentOverride;
const options = buildThinkingOptions(levels);
const defaultValue = normalizeThinkingOptionValue(defaultLevel);
const inherited = {
value: defaultValue,
displayLabel: formatInheritedThinkingLabel(defaultLevel),
};
const selectionValue = effectiveOverride || defaultValue;
const selectionIndex = options.findIndex((option) => option.value === selectionValue);
const source = effectiveOverride ? "override" : "default";
const displayLabel = effectiveOverride
? (options[selectionIndex]?.label ?? formatThinkingOverrideLabel(effectiveOverride))
: inherited.displayLabel;
return {
currentOverride: effectiveOverride,
defaultLabel: formatInheritedThinkingLabel(defaultLevel),
defaultValue: normalizeThinkingOptionValue(defaultLevel),
options: buildThinkingOptions(levels),
selection:
selectionIndex >= 0
? { kind: "anchored", source, value: selectionValue, displayLabel, index: selectionIndex }
: { kind: "unanchored", source, value: selectionValue, displayLabel },
inherited,
options,
};
}
@@ -5,21 +5,17 @@ import { t } from "../../../i18n/index.ts";
import type {
ChatFastModeSelectState,
ChatFastModeSelectValue,
ChatModelSelectOption,
} from "../../../lib/chat/model-select-state.ts";
import { formatThinkingOverrideLabel } from "../../../lib/chat/thinking.ts";
import type { ChatThinkingSelectState } from "../../../lib/chat/thinking.ts";
type ChatEffortPickerParams = {
disabled: boolean;
disabledReason?: string;
fastMode: ChatFastModeSelectState;
selectedThinkingValue: string;
sessionKey: string;
showFastMode: boolean;
thinkingDefaultValue: string;
thinkingDisabled: boolean;
thinkingOptions: ChatModelSelectOption[];
triggerThinkingLabel: string;
thinking: ChatThinkingSelectState;
onFastModeSelect: (value: ChatFastModeSelectValue, sessionKey: string) => Promise<unknown>;
onRequestUpdate?: () => void;
onThinkingSelect: (value: string, sessionKey: string) => Promise<unknown>;
@@ -30,37 +26,24 @@ function formatEffortLabel(label: string): string {
}
export function renderChatEffortPicker(params: ChatEffortPickerParams) {
const sliderStops = params.thinkingOptions.filter((option) => option.value !== "");
const sliderStops = params.thinking.options;
const showReasoning = sliderStops.length > 0;
if (!showReasoning && (!params.showFastMode || !params.fastMode.supported)) {
return nothing;
}
const defaultStopIndex = sliderStops.findIndex(
(option) => option.value === params.thinkingDefaultValue,
);
const hasThinkingOverride = params.selectedThinkingValue !== "";
const overrideStopIndex = sliderStops.findIndex(
(option) => option.value === params.selectedThinkingValue,
);
const sliderIndex = Math.max(hasThinkingOverride ? overrideStopIndex : defaultStopIndex, 0);
const sliderUnanchored = !hasThinkingOverride && defaultStopIndex < 0;
const selection = params.thinking.selection;
const hasThinkingOverride = selection.source === "override";
const selectedThinkingValue = hasThinkingOverride ? selection.value : "";
const sliderIndex = selection.kind === "anchored" ? selection.index : 0;
const sliderUnanchored = selection.kind === "unanchored";
const sliderFillPercent = (index: number) =>
sliderStops.length > 1 ? (index / (sliderStops.length - 1)) * 100 : 0;
const defaultLevelLabel = formatThinkingOverrideLabel(params.thinkingDefaultValue);
const selectedThinkingOption = params.thinkingOptions.find(
(option) => option.value === params.selectedThinkingValue,
);
const reasoningValueText = hasThinkingOverride
? formatEffortLabel(
selectedThinkingOption?.label ?? formatThinkingOverrideLabel(params.selectedThinkingValue),
)
: defaultLevelLabel;
const defaultLevelLabel = formatEffortLabel(params.thinking.inherited.displayLabel);
const reasoningValueText = formatEffortLabel(selection.displayLabel);
const reasoningValueLabel = hasThinkingOverride
? reasoningValueText
: t("chat.modelControls.defaultWithLevel", { level: defaultLevelLabel });
const triggerLabel = showReasoning
? formatEffortLabel(params.triggerThinkingLabel)
: t("chat.modelControls.fastMode");
const triggerLabel = showReasoning ? reasoningValueText : t("chat.modelControls.fastMode");
const triggerTitle = params.fastMode.active
? `${triggerLabel} · ${t("chat.modelControls.fastMode")}`
: triggerLabel;
@@ -116,7 +99,7 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
const input = event.currentTarget as HTMLInputElement;
const stop = sliderStops[Number(input.value)];
resetSliderPreview(input);
if (params.thinkingDisabled || !stop || stop.value === params.selectedThinkingValue) {
if (params.thinkingDisabled || !stop || stop.value === selectedThinkingValue) {
return;
}
commitThinking(stop.value);
@@ -133,8 +116,7 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
}
};
const onlyStop = sliderStops.length === 1 ? sliderStops[0] : undefined;
const effectiveThinkingValue = params.selectedThinkingValue || params.thinkingDefaultValue;
const onlyStopSelected = onlyStop?.value === effectiveThinkingValue;
const onlyStopSelected = selection.kind === "anchored" && selection.index === 0;
const speedTooltip = params.fastMode.supported
? t("chat.modelControls.fastHelp")
: t("chat.modelControls.speedUnsupported");
@@ -146,7 +128,7 @@ export function renderChatEffortPicker(params: ChatEffortPickerParams) {
? "chat-controls__effort-trigger--fast"
: ""} ${params.disabled ? "chat-controls__inline-select-trigger--disabled" : ""}"
data-chat-thinking-select="true"
data-chat-thinking-value=${params.selectedThinkingValue}
data-chat-thinking-value=${selectedThinkingValue}
data-chat-thinking-disabled=${params.thinkingDisabled ? "true" : "false"}
data-chat-fast-mode=${params.fastMode.active ? "true" : "false"}
aria-label=${`${t("chat.selectors.thinkingLevel")}: ${triggerTitle}`}
@@ -142,10 +142,6 @@ function formatPickerModelLabel(label: string): string {
return match?.[1] ?? label;
}
function formatPickerThinkingLabel(label: string): string {
return label.replace(/^Inherited:\s*/u, "");
}
export function renderChatModelControls(props: ChatModelControlsProps) {
const {
currentOverride,
@@ -245,11 +241,6 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
currentOverride || pickerDefaultLabel,
props.modelCatalog,
));
const committedThinkingLabel =
thinking.currentOverride === ""
? thinking.defaultLabel
: (thinking.options.find((entry) => entry.value === thinking.currentOverride)?.label ??
thinking.currentOverride);
const managedCatalog = props.modelCatalogState ?? {
hasSnapshot: !props.modelsLoading,
status: props.modelsLoading ? ("loading" as const) : ("ready" as const),
@@ -281,7 +272,7 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
commonDisabled ||
effortMutationDisabled ||
!managedCatalog.hasSnapshot ||
(thinking.options.length === 0 && thinking.currentOverride === "");
(thinking.options.length === 0 && thinking.selection.source === "default");
const showFastMode = props.showFastMode !== false;
const effortDisabled =
commonDisabled ||
@@ -313,13 +304,10 @@ export function renderChatModelControls(props: ChatModelControlsProps) {
...fastMode,
disabled: fastMode.disabled || commonDisabled || effortMutationDisabled,
},
selectedThinkingValue: thinking.currentOverride,
sessionKey: props.sessionKey,
showFastMode,
thinkingDefaultValue: thinking.defaultValue,
thinkingDisabled,
thinkingOptions: [{ value: "", label: thinking.defaultLabel }, ...thinking.options],
triggerThinkingLabel: formatPickerThinkingLabel(committedThinkingLabel),
thinking,
onFastModeSelect: async (next, targetSessionKey) =>
props.onFastModeSelect?.(next, targetSessionKey),
onRequestUpdate: props.onRequestUpdate,
+120 -2
View File
@@ -174,7 +174,7 @@ describe("new-session model runtime", () => {
expect(request).not.toHaveBeenCalled();
});
it("restores a browser preference only after the model and thinking level validate", async () => {
it("preserves a browser preference when an older server omits thinking profiles", async () => {
const { context } = contextWith([
{
id: "gpt-5.6-sol",
@@ -631,7 +631,17 @@ describe("new-session model runtime", () => {
it("drops a stored reasoning override when its option is no longer available", async () => {
const { context, request } = contextWith([
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", provider: "openai", reasoning: true },
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
provider: "openai",
reasoning: true,
thinkingLevels: [
{ id: "off", label: "off" },
{ id: "high", label: "high" },
],
thinkingDefault: "high",
},
]);
const onSelectionChange = vi.fn();
const control = new NewSessionModelControl(() => undefined, onSelectionChange);
@@ -654,6 +664,114 @@ describe("new-session model runtime", () => {
});
});
it("keeps xhigh anchored to the selected model profile across an interactive model switch", async () => {
const levels = (ids: string[]) => ids.map((id) => ({ id, label: id }));
const { context, request } = contextWith([
{
id: "k3",
name: "Kimi K3",
provider: "kimi",
reasoning: true,
thinkingLevels: levels([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
]),
thinkingDefault: "high",
},
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
provider: "openai",
reasoning: true,
thinkingLevels: levels(["off", "minimal", "low", "medium", "high", "xhigh", "max"]),
thinkingDefault: "medium",
},
]);
const onSelectionChange = vi.fn();
const control = new NewSessionModelControl(() => undefined, onSelectionChange);
control.load(context, "main", true);
await vi.waitFor(() => {
expect(request).toHaveBeenCalledOnce();
expect(
renderControl(control, context).querySelector(
'[data-chat-model-option="openai/gpt-5.6-sol"]',
),
).not.toBeNull();
});
control.selected = "kimi/k3";
control.thinkingLevel = "xhigh";
renderControl(control, context)
.querySelector<HTMLButtonElement>('[data-chat-model-option="openai/gpt-5.6-sol"]')
?.click();
expect(control.selected).toBe("openai/gpt-5.6-sol");
expect(control.thinkingLevel).toBe("xhigh");
expect(onSelectionChange).toHaveBeenLastCalledWith({
model: "openai/gpt-5.6-sol",
thinkingLevel: "xhigh",
});
const container = renderControl(control, context);
const slider = container.querySelector<HTMLInputElement>('[data-chat-thinking-slider="true"]');
expect(slider?.dataset.chatThinkingValues).toBe("off,minimal,low,medium,high,xhigh,max");
expect(slider?.value).toBe("5");
expect(slider?.max).toBe("6");
expect(slider?.getAttribute("aria-valuetext")).toBe("Extra high");
expect(
Number.parseFloat(slider?.style.getPropertyValue("--reasoning-fill") ?? "0"),
).toBeCloseTo(83.33, 1);
});
it("clears xhigh when an interactive model switch targets a profile ending at high", async () => {
const levels = (ids: string[]) => ids.map((id) => ({ id, label: id }));
const { context, request } = contextWith([
{
id: "k3",
name: "Kimi K3",
provider: "kimi",
reasoning: true,
thinkingLevels: levels(["off", "low", "medium", "high", "xhigh"]),
thinkingDefault: "high",
},
{
id: "limited",
name: "Limited",
provider: "demo",
reasoning: true,
thinkingLevels: levels(["off", "low", "medium", "high"]),
thinkingDefault: "medium",
},
]);
const onSelectionChange = vi.fn();
const control = new NewSessionModelControl(() => undefined, onSelectionChange);
control.load(context, "main", true);
await vi.waitFor(() => {
expect(request).toHaveBeenCalledOnce();
expect(
renderControl(control, context).querySelector('[data-chat-model-option="demo/limited"]'),
).not.toBeNull();
});
control.selected = "kimi/k3";
control.thinkingLevel = "xhigh";
renderControl(control, context)
.querySelector<HTMLButtonElement>('[data-chat-model-option="demo/limited"]')
?.click();
expect(control.selected).toBe("demo/limited");
expect(control.thinkingLevel).toBe("");
expect(onSelectionChange).toHaveBeenLastCalledWith({
model: "demo/limited",
thinkingLevel: "",
});
});
it("uses model catalog runtime metadata for an explicit cloud target", async () => {
const { context, request } = contextWith([
{
+58 -65
View File
@@ -14,7 +14,7 @@ import {
normalizeChatModelProviderId,
resolvePreferredServerChatModelValue,
} from "../../lib/chat/model-ref.ts";
import { resolveChatThinkingSelectState } from "../../lib/chat/thinking.ts";
import { normalizeThinkingOptionValue } from "../../lib/chat/thinking.ts";
import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import {
@@ -44,6 +44,11 @@ type NewSessionMetadataLoad = {
};
type CatalogCreateTarget = Pick<SessionCatalog, "id" | "label">;
type ReconciledNewSessionSelection = {
model: string;
thinkingLevel: string;
repaired: boolean;
};
function abortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
@@ -480,65 +485,59 @@ export class NewSessionModelControl {
if (!preference) {
return;
}
// A same-agent metadata refresh revalidates the stored pair. Clear the
// previous restoration first so retired catalog entries cannot survive.
this.selected = "";
this.thinkingLevel = "";
const preferredTarget = preference.model
? resolveDraftModelTarget(preference.model, undefined, this.catalog)
: null;
if (
preference.model &&
(!preferredTarget?.entry || preferredTarget.entry.available === false)
) {
this.onSelectionChange({ model: "", thinkingLevel: "" });
return;
}
this.selected = preferredTarget?.entry
? buildQualifiedChatModelValue(preferredTarget.entry.id, preferredTarget.entry.provider)
: "";
if (!preference.thinkingLevel) {
return;
}
const sourceResult = context?.sessions.state.result ?? null;
const agentDefaultModel = agent?.model?.primary;
const defaultTarget = resolveDraftModelTarget(
agentDefaultModel ?? sourceResult?.defaults.model,
agentDefaultModel ? undefined : sourceResult?.defaults.modelProvider,
this.catalog,
const selection = this.reconcileSelection(
preference.model ?? "",
preference.thinkingLevel ?? "",
{ agent, context },
);
const selectedTarget = resolveDraftModelTarget(this.selected, undefined, this.catalog);
const draftRow: GatewaySessionRow = {
key: "new-session:preference",
kind: "direct",
updatedAt: null,
...(selectedTarget
? { model: selectedTarget.model, modelProvider: selectedTarget.provider ?? undefined }
: {}),
};
const thinking = resolveChatThinkingSelectState({
catalog: this.catalog,
defaults: {
...sourceResult?.defaults,
modelProvider: defaultTarget?.provider ?? sourceResult?.defaults.modelProvider ?? null,
model: defaultTarget?.model ?? sourceResult?.defaults.model ?? null,
contextTokens: sourceResult?.defaults.contextTokens ?? null,
agentRuntime: agent?.agentRuntime ?? sourceResult?.defaults.agentRuntime,
thinkingLevels: agent?.thinkingLevels ?? sourceResult?.defaults.thinkingLevels,
thinkingOptions: agent?.thinkingOptions ?? sourceResult?.defaults.thinkingOptions,
thinkingDefault: agent?.thinkingDefault ?? sourceResult?.defaults.thinkingDefault,
},
session: draftRow,
sessionKey: draftRow.key,
sessionsResult: sourceResult,
});
if (thinking.options.some((entry) => entry.value === preference.thinkingLevel)) {
this.thinkingLevel = preference.thinkingLevel;
} else {
this.onSelectionChange({ model: this.selected, thinkingLevel: "" });
this.selected = selection.model;
this.thinkingLevel = selection.thinkingLevel;
if (selection.repaired) {
this.onSelectionChange({ model: selection.model, thinkingLevel: selection.thinkingLevel });
}
}
private reconcileSelection(
model: string,
thinkingLevel: string,
options: { agent?: GatewayAgentRow; context: ApplicationContext | undefined },
): ReconciledNewSessionSelection {
const requestedModel = model.trim();
const selectedTarget = requestedModel
? resolveDraftModelTarget(requestedModel, undefined, this.catalog)
: null;
if (requestedModel && (!selectedTarget?.entry || selectedTarget.entry.available === false)) {
return { model: "", thinkingLevel: "", repaired: true };
}
const selected = selectedTarget?.entry
? buildQualifiedChatModelValue(selectedTarget.entry.id, selectedTarget.entry.provider)
: "";
if (!thinkingLevel) {
return { model: selected, thinkingLevel: "", repaired: false };
}
const defaults = options.context?.sessions.state.result?.defaults;
const agentDefaultModel = options.agent?.model?.primary;
const defaultTarget = selected
? null
: resolveDraftModelTarget(
agentDefaultModel ?? defaults?.model,
agentDefaultModel ? undefined : defaults?.modelProvider,
this.catalog,
);
const targetEntry = selectedTarget?.entry ?? defaultTarget?.entry;
const authoritativeLevels = selected
? targetEntry?.thinkingLevels
: (options.agent?.thinkingLevels ?? defaults?.thinkingLevels ?? targetEntry?.thinkingLevels);
const normalizedThinking = normalizeThinkingOptionValue(thinkingLevel);
const supported = authoritativeLevels?.some(
(level) => normalizeThinkingOptionValue(level.id) === normalizedThinking,
);
if (targetEntry?.reasoning === false || (authoritativeLevels !== undefined && !supported)) {
return { model: selected, thinkingLevel: "", repaired: true };
}
return { model: selected, thinkingLevel, repaired: false };
}
resolveAgentRuntimeId(options: {
agent?: GatewayAgentRow;
context: ApplicationContext | undefined;
@@ -644,15 +643,9 @@ export class NewSessionModelControl {
onModelSelect: (value) => {
this.selectionGeneration += 1;
this.restoringPreference = false;
this.selected = value;
const target = resolveDraftModelTarget(
value || agentDefaultModel || sourceResult?.defaults.model,
value || agentDefaultModel ? undefined : sourceResult?.defaults.modelProvider,
this.catalog,
);
if (target?.entry?.reasoning === false) {
this.thinkingLevel = "";
}
const selection = this.reconcileSelection(value, this.thinkingLevel, options);
this.selected = selection.model;
this.thinkingLevel = selection.thinkingLevel;
this.onSelectionChange({ model: this.selected, thinkingLevel: this.thinkingLevel });
},
onModelPickerTargetSelect: (groupId, catalogId) => {