diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index da54e87adde8..db7ece7792bb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 4a1bbdf2fcd0..d1552a917e2a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index c1db7b6b603f..4440e48f1aa5 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"cea38cb2354e456c9dcb62fcb59d4c5a1850eb7b35cf2c6dc1e6b4052cb239f2","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"b8a161ee64194146ccfadc417f27d39911eea97ee818538df86653d1a9f414cc","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index 2d9266aba832..e3b4ac7f1771 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"be5e5a878566af7ed7371bcc8bf6501771b7366c019e2e8132b1bd3d598b7b48","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"77f828ec162318163cf147fc99143fcafaf35cfd2e2654559fd14bd778ae43d9","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index b655d7fc6a22..4fea49217272 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index eccdad20a958..5cda4d94dc7b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"3642828e0c335f3412a575e04ac5179c82226386942fcaecaf6ecf229cefb246","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"fe0b763f911c6f67d25d63b6fd0defe104bef798008f830ae0f6dbd013a79ff6","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index d0602845248d..a5c4df338907 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"b976e3217681b57aab8ecab4a94ecf0f8238fe4c68de6c7bb9e2588e9c726622","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"d8f4fbf15031d5731596db8f48d15d28c2522e6d563ab0bd7fa41f4908c53314","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index fa3243fbeed2..1898fbf1097e 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json index c85d3601c4e6..07d2cd7055eb 100644 --- a/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json +++ b/docs/.generated/plugin-sdk-api-baseline/command-auth-native.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index d472fdd43dee..d43b4526941c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"d22bf2e5ae14ee5ec01ea825c2bc16c908a15f78dd462c3e160393aa871d7cf5","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"a501e9c9f95cda7027f83d52c7b4b95153531cc24540f0e6803435e75def6153","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index a04337202e29..f7a44026510f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"66c63de1253ea9f27ada35301d7f9a53504483c10ca5dd281fe50b645b5bccd2","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"ceca2545902bcea34a836763afef76c9b55bd0fa639cceb89b7122c0f7c6027a","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index cea4ac7a107e..99ef85a5bde1 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"e6d85aba0cc65a4acbfb40ebcb26efda2cc7510f646141180d50cec22c07f1c0","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"d83a8d16c27fdc15ee35827799d7a245b2b46e6a5b8b98b1a1b623d466a1dbd4","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index 94ad586bebc7..37df5967b740 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index e8bf4f630bd5..13a574cda87c 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"538b128a5de8b38c9c3e13cdbb74d0eaa0e6ce7a8066c5852204b77b37536734","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"35904f50b6b6826386c774052cf3ab38a07a5ccabbd264237c1ce0bd5f1275c8","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 48b6af5cce5a..a52cc293e5e3 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"9e50d560be0fae99edf2ab5400625033c3515d00b16113b085543d6f218c17cd","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"3e7a63e4f1d24dd6a2af9b7c5fd47837781471f5d92cdc1ba15f079259617402","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 16700279f529..c428f5492a5f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"9748f103ca50929f6a67e77c6708c0fb1a24e5d83ec1ae9e15485a80fdc20398","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"7f5d4e9c2f0593f433cb06c9acbca4c7a683e7d097d8f11304450076a7deb6e7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 5bf9c996e115..02f173e9c434 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -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"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 36edd015a6f7..678fb91d6252 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"a204b23b49393b23755ed3f6d5f44c14248abba916063c6a4f363d39a019c800","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"cc7dd850ee1af91876e83827b5087c1374328f8eb7b9d559c5df7e34695663e2","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index 010c789252ad..cf52992d7f0a 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"ed3e64c4f91e222c62209dbb7ae7632ff3ea85ae5658264b559d36997f037b7b","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"6c157ca687f5bf384fcf526ca2465f85b27e3bedba0bde3c8ecf1e4060ddcd5d","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index 065828b2d838..db3c75a724ec 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -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" }] }, { diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 56fd03ad0282..bf381945cb5c 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -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), }); diff --git a/src/agents/thinking-runtime.ts b/src/agents/thinking-runtime.ts index 96a82e86ee18..26b318094f5a 100644 --- a/src/agents/thinking-runtime.ts +++ b/src/agents/thinking-runtime.ts @@ -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; @@ -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, diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test.ts b/src/gateway/server-methods/models-list-result.openai-routes.test.ts index 6e92cf926708..0c2a907bcee6 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test.ts @@ -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, - }, + }), ], }); }, diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index eea3745ac731..235ad34b73b8 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -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; 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): ModelsListView { +function resolveModelsListView(params: Record): 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 { - return await Promise.all( + return Promise.all( params.catalog.map(async (entry): Promise => { 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, diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index 4039d681dfe0..cc524b27c273 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -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> }; + 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", diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 94e5d456e7ae..ba0f15aa43aa 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -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"]); diff --git a/src/gateway/session-utils-model.ts b/src/gateway/session-utils-model.ts index cfff3d77db8c..d7b56dddf769 100644 --- a/src/gateway/session-utils-model.ts +++ b/src/gateway/session-utils-model.ts @@ -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; defaultLevel: ReturnType; } { + 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, }; } diff --git a/src/gateway/session-utils-store.ts b/src/gateway/session-utils-store.ts index 774c617e6063..749ac4bf2248 100644 --- a/src/gateway/session-utils-store.ts +++ b/src/gateway/session-utils-store.ts @@ -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 } : {}, ); diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index a8640419d8d5..fbbd0ced149a 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -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">; diff --git a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts index e6044e163bb4..181ccd53bd25 100644 --- a/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts +++ b/ui/src/e2e/new-session-page.workspace-memory.e2e.test.ts @@ -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, { diff --git a/ui/src/lib/chat/model-select-state.ts b/ui/src/lib/chat/model-select-state.ts index 36bf4b32998e..4dd40ae0887c 100644 --- a/ui/src/lib/chat/model-select-state.ts +++ b/ui/src/lib/chat/model-select-state.ts @@ -26,7 +26,7 @@ type ChatModelSelectStateInput = { sessionsResult: SessionsListResult | null; }; -export type ChatModelSelectOption = { +type ChatModelSelectOption = { value: string; label: string; }; diff --git a/ui/src/lib/chat/thinking.test.ts b/ui/src/lib/chat/thinking.test.ts index 9fca501a1cc6..8fac02df7478 100644 --- a/ui/src/lib/chat/thinking.test.ts +++ b/ui/src/lib/chat/thinking.test.ts @@ -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: { diff --git a/ui/src/lib/chat/thinking.ts b/ui/src/lib/chat/thinking.ts index e2f6831973ae..12abc748150e 100644 --- a/ui/src/lib/chat/thinking.ts +++ b/ui/src/lib/chat/thinking.ts @@ -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, }; } diff --git a/ui/src/pages/chat/components/chat-effort-picker.ts b/ui/src/pages/chat/components/chat-effort-picker.ts index 3f0f76dd954f..e1ad7ffa90d0 100644 --- a/ui/src/pages/chat/components/chat-effort-picker.ts +++ b/ui/src/pages/chat/components/chat-effort-picker.ts @@ -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; onRequestUpdate?: () => void; onThinkingSelect: (value: string, sessionKey: string) => Promise; @@ -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}`} diff --git a/ui/src/pages/chat/components/chat-model-controls.ts b/ui/src/pages/chat/components/chat-model-controls.ts index 11aa6a0e525c..505c6bba2580 100644 --- a/ui/src/pages/chat/components/chat-model-controls.ts +++ b/ui/src/pages/chat/components/chat-model-controls.ts @@ -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, diff --git a/ui/src/pages/new-session/model-control.test.ts b/ui/src/pages/new-session/model-control.test.ts index eb212e87aade..c75a415b649a 100644 --- a/ui/src/pages/new-session/model-control.test.ts +++ b/ui/src/pages/new-session/model-control.test.ts @@ -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('[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('[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('[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([ { diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index d5e19a81c46b..b97b2e86dc69 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -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; +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) => {