diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 4699647c..fb9184a0 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -548,7 +548,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("treats ultrathink as a prompt keyword instead of a session effort", () => { + it.effect("maps ultrathink on Sonnet 4.6 to effort=high with max thinking budget", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -577,7 +577,11 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.equal(createInput?.options.effort, undefined); + // Sonnet 4.6 has no "max" effort, so ultrathink collapses to "high". + assert.equal(createInput?.options.effort, "high"); + // Thinking budget is bumped to the ultrathink default. + assert.equal(createInput?.options.maxThinkingTokens, 63999); + // Prompt prefix is still applied on top of the SDK boost. const promptText = yield* Effect.promise(() => readFirstPromptText(createInput)); assert.equal(promptText, "Ultrathink:\nInvestigate the edge cases"); }).pipe( @@ -586,6 +590,63 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("maps ultrathink on Opus 4.7 to effort=max with max thinking budget", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-opus-4-7", + runtimeMode: "full-access", + modelOptions: { + claudeAgent: { + effort: "ultrathink", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + // Opus 4.7 supports "max", so ultrathink gets the top effort level. + assert.equal(createInput?.options.effort, "max"); + assert.equal(createInput?.options.maxThinkingTokens, 63999); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("preserves user-provided maxThinkingTokens when higher than ultrathink default", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + model: "claude-opus-4-7", + runtimeMode: "full-access", + providerOptions: { + claudeAgent: { + maxThinkingTokens: 90000, + }, + }, + modelOptions: { + claudeAgent: { + effort: "ultrathink", + }, + }, + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "max"); + // User override is higher than the ultrathink default, so it passes through. + assert.equal(createInput?.options.maxThinkingTokens, 90000); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("embeds image attachments in Claude user messages", () => { const baseDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-")); const harness = makeHarness({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index c2c44933..6a344edb 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -42,8 +42,8 @@ import { } from "@okcode/contracts"; import { applyClaudePromptEffortPrefix, - getEffectiveClaudeCodeEffort, getReasoningEffortOptions, + resolveClaudeUltrathinkSdkConfig, resolveReasoningEffortForProvider, supportsClaudeFastMode, supportsClaudeThinkingToggle, @@ -2811,7 +2811,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { supportsClaudeThinkingToggle(input.model) ? input.modelOptions.claudeAgent.thinking : undefined; - const effectiveEffort = getEffectiveClaudeCodeEffort(effort); + const { effort: sdkEffort, maxThinkingTokens: sdkMaxThinkingTokens } = + resolveClaudeUltrathinkSdkConfig( + input.model, + effort, + providerOptions?.maxThinkingTokens ?? null, + ); const permissionMode = toPermissionMode(providerOptions?.permissionMode) ?? (input.runtimeMode === "full-access" ? "bypassPermissions" : undefined); @@ -2833,13 +2838,13 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(input.model ? { model: input.model } : {}), pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", settingSources: [...CLAUDE_SETTING_SOURCES], - ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(sdkEffort ? { effort: sdkEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}), - ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + ...(sdkMaxThinkingTokens !== undefined + ? { maxThinkingTokens: sdkMaxThinkingTokens } : {}), ...(Object.keys(settings).length > 0 ? { settings } : {}), ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), @@ -2930,10 +2935,10 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { config: { ...(input.model ? { model: input.model } : {}), ...(input.cwd ? { cwd: input.cwd } : {}), - ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(sdkEffort ? { effort: sdkEffort } : {}), ...(permissionMode ? { permissionMode } : {}), - ...(providerOptions?.maxThinkingTokens !== undefined - ? { maxThinkingTokens: providerOptions.maxThinkingTokens } + ...(sdkMaxThinkingTokens !== undefined + ? { maxThinkingTokens: sdkMaxThinkingTokens } : {}), ...(fastMode ? { fastMode: true } : {}), }, diff --git a/apps/server/src/provider/codexConfig.ts b/apps/server/src/provider/codexConfig.ts index eb184be0..07b9ec26 100644 --- a/apps/server/src/provider/codexConfig.ts +++ b/apps/server/src/provider/codexConfig.ts @@ -11,9 +11,12 @@ import { import { Effect, FileSystem, Result } from "effect"; import { parse as parseToml } from "toml"; +import { probeCodexLocalBackends } from "./codexLocalBackendProbe.ts"; + export interface CodexConfigReadOptions { readonly homePath?: string | null | undefined; readonly env?: NodeJS.ProcessEnv | undefined; + readonly probeLocalBackends?: boolean | undefined; } function emptyCodexConfigSummary(): ServerCodexConfigSummary { @@ -182,19 +185,42 @@ export const readCodexConfigSummary = (options: CodexConfigReadOptions = {}) => const fileSystem = yield* FileSystem.FileSystem; const configPath = resolveCodexConfigPath(options); const exists = yield* fileSystem.exists(configPath).pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return emptyCodexConfigSummary(); - } - const content = yield* fileSystem.readFileString(configPath).pipe(Effect.result); - if (Result.isFailure(content)) { - return { - ...emptyCodexConfigSummary(), - parseError: getParseErrorMessage(content.failure), - }; + const baseSummary: ServerCodexConfigSummary = yield* Effect.gen(function* () { + if (!exists) { + return emptyCodexConfigSummary(); + } + + const content = yield* fileSystem.readFileString(configPath).pipe(Effect.result); + if (Result.isFailure(content)) { + return { + ...emptyCodexConfigSummary(), + parseError: getParseErrorMessage(content.failure), + } satisfies ServerCodexConfigSummary; + } + + return summarizeCodexConfigToml(content.success); + }); + + if (options.probeLocalBackends !== true) { + return baseSummary; } - return summarizeCodexConfigToml(content.success); + const probes = yield* probeCodexLocalBackends(); + + return { + ...baseSummary, + detectedLocalBackends: { + ollama: + probes.ollama.modelCount !== undefined + ? { reachable: probes.ollama.reachable, modelCount: probes.ollama.modelCount } + : { reachable: probes.ollama.reachable }, + lmstudio: + probes.lmstudio.modelCount !== undefined + ? { reachable: probes.lmstudio.reachable, modelCount: probes.lmstudio.modelCount } + : { reachable: probes.lmstudio.reachable }, + }, + } satisfies ServerCodexConfigSummary; }); export function usesOpenAiLoginForSelectedCodexBackend(summary: ServerCodexConfigSummary): boolean { diff --git a/apps/server/src/provider/codexLocalBackendProbe.ts b/apps/server/src/provider/codexLocalBackendProbe.ts new file mode 100644 index 00000000..9d12e1b0 --- /dev/null +++ b/apps/server/src/provider/codexLocalBackendProbe.ts @@ -0,0 +1,133 @@ +import { Effect } from "effect"; + +export interface LocalBackendProbeResult { + readonly reachable: boolean; + readonly modelCount?: number; + readonly error?: string; +} + +export interface LocalBackendProbes { + readonly ollama: LocalBackendProbeResult; + readonly lmstudio: LocalBackendProbeResult; +} + +const DEFAULT_PROBE_TIMEOUT_MS = 1_500; + +const OLLAMA_TAGS_URL = "http://localhost:11434/api/tags"; +const LM_STUDIO_MODELS_URL = "http://localhost:1234/v1/models"; + +function isSuppressedByEnv(): boolean { + const env = process.env; + return env.OKCODE_DISABLE_LOCAL_BACKEND_PROBES === "1" || env.VITEST === "true"; +} + +function toErrorMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + if (cause.name === "AbortError") { + return "timeout"; + } + return cause.message; + } + if (typeof cause === "string" && cause.trim().length > 0) { + return cause; + } + return fallback; +} + +function readModelCount(data: unknown, key: "models" | "data"): number | undefined { + if (!data || typeof data !== "object") { + return undefined; + } + const value = (data as Record)[key]; + if (Array.isArray(value)) { + return value.length; + } + return undefined; +} + +async function probeHttp(input: { + readonly url: string; + readonly modelsKey: "models" | "data"; + readonly timeoutMs: number; +}): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), input.timeoutMs); + try { + const response = await fetch(input.url, { + method: "GET", + signal: controller.signal, + headers: { accept: "application/json" }, + }); + if (!response.ok) { + return { + reachable: false, + error: `HTTP ${response.status}`, + }; + } + try { + const body: unknown = await response.json(); + const modelCount = readModelCount(body, input.modelsKey); + return modelCount !== undefined ? { reachable: true, modelCount } : { reachable: true }; + } catch (cause) { + // Server responded 2xx but body wasn't JSON — still counts as reachable. + return { + reachable: true, + error: toErrorMessage(cause, "Non-JSON response"), + }; + } + } catch (cause) { + return { + reachable: false, + error: toErrorMessage(cause, "Network error"), + }; + } finally { + clearTimeout(timeout); + } +} + +export interface ProbeLocalBackendOptions { + readonly timeoutMs?: number | undefined; +} + +const UNREACHABLE_STUB: LocalBackendProbeResult = { reachable: false }; + +export const probeOllama = ( + options: ProbeLocalBackendOptions = {}, +): Effect.Effect => { + if (isSuppressedByEnv()) { + return Effect.succeed(UNREACHABLE_STUB); + } + return Effect.promise(() => + probeHttp({ + url: OLLAMA_TAGS_URL, + modelsKey: "models", + timeoutMs: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS, + }), + ); +}; + +export const probeLmStudio = ( + options: ProbeLocalBackendOptions = {}, +): Effect.Effect => { + if (isSuppressedByEnv()) { + return Effect.succeed(UNREACHABLE_STUB); + } + return Effect.promise(() => + probeHttp({ + url: LM_STUDIO_MODELS_URL, + modelsKey: "data", + timeoutMs: options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS, + }), + ); +}; + +export const probeCodexLocalBackends = ( + options: ProbeLocalBackendOptions = {}, +): Effect.Effect => + Effect.all( + { + ollama: probeOllama(options), + lmstudio: probeLmStudio(options), + }, + { concurrency: "unbounded" }, + ); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 4872d916..47e6fe20 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -96,6 +96,10 @@ const defaultCodexConfigSummary = { selectedModelProviderId: null, entries: [], parseError: null, + detectedLocalBackends: { + ollama: { reachable: false }, + lmstudio: { reachable: false }, + }, } as const; const expectedServerBuildInfo = expect.objectContaining({ diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 47ab65ba..31168ab1 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1640,7 +1640,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case WS_METHODS.serverGetConfig: const keybindingsConfig = yield* keybindingsManager.loadConfigState; const providers = yield* getProviderStatuses(); - const codexConfig = yield* readCodexConfigSummary(); + const codexConfig = yield* readCodexConfigSummary({ probeLocalBackends: true }); return { cwd, keybindingsConfigPath, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9a6b4c47..17bf1656 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5415,6 +5415,10 @@ export default function ChatView({ : selectableProviders ).includes(provider.provider), )} + codexSelectedModelProviderId={ + serverConfigQuery.data?.codexConfig?.selectedModelProviderId ?? null + } + openclawGatewayUrl={settings.openclawGatewayUrl} {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index d627b1bc..497443d6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -17,6 +17,37 @@ import { MenuTrigger, } from "../ui/menu"; +const CODEX_LOCAL_BACKEND_LABELS: Record = { + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +function getCodexLocalBackendLabel(id: string | null | undefined): string | null { + if (typeof id !== "string") { + return null; + } + return CODEX_LOCAL_BACKEND_LABELS[id] ?? null; +} + +type OpenclawGatewayBadge = "connected" | "url-configured" | null; + +function getOpenclawGatewayBadge(input: { + readonly snapshot: ServerProviderStatus | null; + readonly gatewayUrl: string | null | undefined; +}): OpenclawGatewayBadge { + const snapshot = input.snapshot; + if (snapshot !== null) { + const isAvailable = snapshot.available === true || snapshot.enabled === true; + if (snapshot.status === "ready" && isAvailable) { + return "connected"; + } + } + if (typeof input.gatewayUrl === "string" && input.gatewayUrl.trim().length > 0) { + return "url-configured"; + } + return null; +} + const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, @@ -50,6 +81,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { lockedProvider: ProviderKind | null; providers: ReadonlyArray; activeProviderIconClassName?: string; + codexSelectedModelProviderId?: string | null | undefined; + openclawGatewayUrl?: string | null | undefined; compact?: boolean; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; @@ -61,9 +94,16 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { ? [props.lockedProvider] : props.providers.map((provider) => provider.provider); const activeProviderSnapshot = getProviderSnapshot(props.providers, activeProvider); - const selectedModelLabel = + const rawSelectedModelLabel = activeProviderSnapshot?.models?.find((option) => option.slug === props.model)?.name ?? props.model; + const codexLocalBackendLabel = + activeProvider === "codex" + ? getCodexLocalBackendLabel(props.codexSelectedModelProviderId ?? null) + : null; + const selectedModelLabel = codexLocalBackendLabel + ? `${rawSelectedModelLabel} · ${codexLocalBackendLabel}` + : rawSelectedModelLabel; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { @@ -130,6 +170,18 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { return null; } + const openclawBadge = + provider === "openclaw" + ? getOpenclawGatewayBadge({ + snapshot: providerSnapshot, + gatewayUrl: props.openclawGatewayUrl, + }) + : null; + const codexGroupBackendLabel = + provider === "codex" + ? getCodexLocalBackendLabel(props.codexSelectedModelProviderId ?? null) + : null; + return ( {index > 0 ? : null} @@ -143,8 +195,24 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { /> {getProviderLabel(provider)} + {codexGroupBackendLabel ? ` · ${codexGroupBackendLabel}` : ""} {props.lockedProvider === provider ? " · locked for this thread" : ""} + {openclawBadge === "connected" ? ( + + ✓ Connected + + ) : openclawBadge === "url-configured" ? ( + + URL configured + + ) : null}
-
+
{row.title} {row.statusBadge ? ( ) : null} + {row.detectionBadge ? ( + + {row.detectionBadge.reachable + ? typeof row.detectionBadge.modelCount === "number" + ? `Detected · ${row.detectionBadge.modelCount} model${ + row.detectionBadge.modelCount === 1 ? "" : "s" + }` + : "Detected" + : "Not running"} + + ) : null}
{row.id}
diff --git a/apps/web/src/lib/codexBackendCatalog.test.ts b/apps/web/src/lib/codexBackendCatalog.test.ts index 733e1541..500a654b 100644 --- a/apps/web/src/lib/codexBackendCatalog.test.ts +++ b/apps/web/src/lib/codexBackendCatalog.test.ts @@ -38,6 +38,7 @@ describe("buildCodexBackendCatalog", () => { group: "custom", authNote: "Provider-specific credentials", statusBadge: "Configured", + detectionBadge: null, selected: true, definedInConfig: true, isKnownPreset: false, @@ -45,6 +46,43 @@ describe("buildCodexBackendCatalog", () => { ]); }); + it("surfaces reachable local-backend detection badges for ollama and lmstudio", () => { + const catalog = buildCodexBackendCatalog({ + selectedModelProviderId: "ollama", + entries: [ + { + id: "ollama", + selected: true, + definedInConfig: true, + isBuiltIn: false, + isKnownPreset: true, + requiresOpenAiLogin: false, + }, + ], + parseError: null, + detectedLocalBackends: { + ollama: { reachable: true, modelCount: 3 }, + lmstudio: { reachable: false }, + }, + }); + + const ollamaRow = catalog.builtIn.find((row) => row.id === "ollama"); + const lmstudioRow = catalog.builtIn.find((row) => row.id === "lmstudio"); + expect(ollamaRow?.detectionBadge).toEqual({ reachable: true, modelCount: 3 }); + expect(lmstudioRow?.detectionBadge).toEqual({ reachable: false, modelCount: undefined }); + }); + + it("leaves detection badges null when the probe result is absent", () => { + const catalog = buildCodexBackendCatalog({ + selectedModelProviderId: null, + entries: [], + parseError: null, + }); + + const ollamaRow = catalog.builtIn.find((row) => row.id === "ollama"); + expect(ollamaRow?.detectionBadge).toBeNull(); + }); + it("renders openai as the implicit default when no model_provider is configured", () => { const catalog = buildCodexBackendCatalog({ selectedModelProviderId: null, diff --git a/apps/web/src/lib/codexBackendCatalog.ts b/apps/web/src/lib/codexBackendCatalog.ts index c8997f59..ee5390ad 100644 --- a/apps/web/src/lib/codexBackendCatalog.ts +++ b/apps/web/src/lib/codexBackendCatalog.ts @@ -13,12 +13,18 @@ export type CodexBackendStatusBadge = | null; export type CodexBackendGroupId = "built-in" | "curated" | "custom"; +export interface CodexBackendDetectionBadge { + readonly reachable: boolean; + readonly modelCount?: number | undefined; +} + export interface CodexBackendCatalogRow { readonly id: string; readonly title: string; readonly group: CodexBackendGroupId; readonly authNote: string; readonly statusBadge: CodexBackendStatusBadge; + readonly detectionBadge: CodexBackendDetectionBadge | null; readonly selected: boolean; readonly definedInConfig: boolean; readonly isKnownPreset: boolean; @@ -39,6 +45,11 @@ const DEFAULT_CODEX_CONFIG_SUMMARY: ServerCodexConfigSummary = { parseError: null, }; +const DETECTABLE_LOCAL_BACKEND_IDS = { + ollama: "ollama", + lmstudio: "lmstudio", +} as const; + function humanizeCodexBackendId(id: string): string { return id .split(/[-_.\s]+/u) @@ -92,6 +103,7 @@ function toCatalogRow(input: { readonly selected: boolean; readonly definedInConfig: boolean; readonly isKnownPreset: boolean; + readonly detectionBadge: CodexBackendDetectionBadge | null; }): CodexBackendCatalogRow { const preset = getCodexModelProviderPreset(input.id); @@ -101,12 +113,36 @@ function toCatalogRow(input: { group: preset ? toPresetGroup(preset.kind) : "custom", authNote: toAuthNote(input.id), statusBadge: getStatusBadge(input), + detectionBadge: input.detectionBadge, selected: input.selected, definedInConfig: input.definedInConfig, isKnownPreset: input.isKnownPreset, }; } +function resolveDetectionBadge( + id: string, + summary: ServerCodexConfigSummary, +): CodexBackendDetectionBadge | null { + const detected = summary.detectedLocalBackends; + if (!detected) { + return null; + } + if (id === DETECTABLE_LOCAL_BACKEND_IDS.ollama) { + return { + reachable: detected.ollama.reachable, + modelCount: detected.ollama.modelCount, + }; + } + if (id === DETECTABLE_LOCAL_BACKEND_IDS.lmstudio) { + return { + reachable: detected.lmstudio.reachable, + modelCount: detected.lmstudio.modelCount, + }; + } + return null; +} + export function buildCodexBackendCatalog( summary: ServerCodexConfigSummary | null | undefined, ): CodexBackendCatalog { @@ -123,6 +159,7 @@ export function buildCodexBackendCatalog( selected: dynamicEntry?.selected ?? false, definedInConfig: dynamicEntry?.definedInConfig ?? false, isKnownPreset: true, + detectionBadge: resolveDetectionBadge(preset.id, resolvedSummary), }); }); @@ -135,6 +172,7 @@ export function buildCodexBackendCatalog( selected: entry.selected, definedInConfig: entry.definedInConfig, isKnownPreset: false, + detectionBadge: resolveDetectionBadge(entry.id, resolvedSummary), }), ); diff --git a/apps/web/src/lib/providerAvailability.test.ts b/apps/web/src/lib/providerAvailability.test.ts index da867a20..a195fe61 100644 --- a/apps/web/src/lib/providerAvailability.test.ts +++ b/apps/web/src/lib/providerAvailability.test.ts @@ -89,6 +89,54 @@ describe("providerAvailability", () => { ).toBe(true); }); + it("shows openclaw as selectable when gateway URL is set but not yet probed", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "openclaw", + statuses: [ + makeStatus("openclaw", { + status: "warning", + available: false, + authStatus: "unknown", + }), + ], + openclawGatewayUrl: "ws://gateway.example/local", + }), + ).toBe(true); + }); + + it("shows openclaw as selectable when status is ready && available", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "openclaw", + statuses: [ + makeStatus("openclaw", { + status: "ready", + available: true, + authStatus: "authenticated", + }), + ], + openclawGatewayUrl: "", + }), + ).toBe(true); + }); + + it("excludes openclaw when gateway URL is blank and status is not ready", () => { + expect( + isProviderReadyForThreadSelection({ + provider: "openclaw", + statuses: [ + makeStatus("openclaw", { + status: "error", + available: false, + authStatus: "unauthenticated", + }), + ], + openclawGatewayUrl: "", + }), + ).toBe(false); + }); + it("returns selectable providers in stable picker order", () => { expect( getSelectableThreadProviders({ diff --git a/apps/web/src/lib/settingsProviderMetadata.tsx b/apps/web/src/lib/settingsProviderMetadata.tsx index 8d7d5fba..c112badc 100644 --- a/apps/web/src/lib/settingsProviderMetadata.tsx +++ b/apps/web/src/lib/settingsProviderMetadata.tsx @@ -80,7 +80,7 @@ export const PROVIDER_AUTH_GUIDES: Record = { installCmd: "npm install -g @openai/codex", authCmd: "codex login", verifyCmd: "codex login status", - note: "Codex appears in the thread picker when the CLI is reachable and the selected backend is either OpenAI-authenticated or a configured non-OpenAI backend.", + note: "Codex appears in the thread picker when the CLI is reachable and the selected backend is either OpenAI-authenticated or a configured non-OpenAI backend. For local models, see the Ollama and LM Studio sections below.", }, claudeAgent: { installCmd: "npm install -g @anthropic-ai/claude-code", @@ -103,6 +103,28 @@ export const PROVIDER_AUTH_GUIDES: Record = { openclaw: { authCmd: "Use gateway shared secret/token", verifyCmd: "Test Connection", - note: "OpenClaw uses the gateway URL and shared secret/token below rather than a local CLI login. Shared-secret auth usually works without device pairing and is the recommended default for Tailscale and remote gateways.", + note: "OpenClaw uses the gateway URL and shared secret/token below rather than a local CLI login. Shared-secret auth usually works without device pairing and is the recommended default for Tailscale and remote gateways. Connection is verified by a WebSocket handshake plus /health probe and a connect handshake; click Test Connection again if the gateway restarts or your network changes.", + }, +}; + +export type LocalBackendKey = "ollama" | "lmstudio"; + +export const LOCAL_BACKEND_LABELS: Record = { + ollama: "Ollama", + lmstudio: "LM Studio", +}; + +export const LOCAL_BACKEND_AUTH_GUIDES: Record = { + ollama: { + installCmd: "brew install ollama # or https://ollama.com/download", + authCmd: "ollama serve # then: ollama pull llama3.1", + verifyCmd: "curl http://localhost:11434/api/tags", + note: 'Ollama is exposed to Codex by setting model_provider = "ollama" in ~/.codex/config.toml. Keep `ollama serve` running (launchd on macOS) so the daemon stays reachable on localhost:11434.', + }, + lmstudio: { + installCmd: "Install LM Studio from https://lmstudio.ai", + authCmd: "Load a model and start the Local Server from the Developer tab", + verifyCmd: "curl http://localhost:1234/v1/models", + note: 'LM Studio is exposed to Codex by setting model_provider = "lmstudio" in ~/.codex/config.toml. The OpenAI-compatible server must be running on localhost:1234 for Codex to pick it up.', }, }; diff --git a/apps/web/src/routes/_chat.settings.index.tsx b/apps/web/src/routes/_chat.settings.index.tsx index 270a6cf5..59fcea50 100644 --- a/apps/web/src/routes/_chat.settings.index.tsx +++ b/apps/web/src/routes/_chat.settings.index.tsx @@ -84,6 +84,9 @@ import { } from "../components/chat/providerStatusPresentation"; import { INSTALL_PROVIDER_SETTINGS, + LOCAL_BACKEND_AUTH_GUIDES, + LOCAL_BACKEND_LABELS, + type LocalBackendKey, PROVIDER_AUTH_GUIDES, SETTINGS_AUTH_PROVIDER_ORDER, } from "../lib/settingsProviderMetadata"; @@ -350,6 +353,73 @@ function AuthenticationStatusCard({ ); } +function LocalBackendAuthenticationCard({ + backend, + detection, +}: { + backend: LocalBackendKey; + detection?: { reachable: boolean; modelCount?: number | undefined } | null; +}) { + const guide = LOCAL_BACKEND_AUTH_GUIDES[backend]; + const label = LOCAL_BACKEND_LABELS[backend]; + const reachable = detection?.reachable === true; + const badgeClassName = reachable + ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" + : "border-slate-500/25 bg-slate-500/10 text-slate-700 dark:text-slate-300"; + const badgeLabel = reachable + ? typeof detection?.modelCount === "number" + ? `Detected · ${detection.modelCount} model${detection.modelCount === 1 ? "" : "s"}` + : "Detected" + : "Not running"; + const heading = reachable + ? `${label} is reachable on localhost` + : `${label} is not running locally yet`; + const description = reachable + ? `${label} is ready to use as a Codex backend. Set model_provider in ~/.codex/config.toml to activate it for new threads.` + : guide.note; + + return ( +
+
+
+
+

{label}

+ + {badgeLabel} + +
+

{heading}

+

{description}

+
+
+ +
+
+
Install
+ + {guide.installCmd ?? "Configured in-app"} + +
+
+
Run
+ {guide.authCmd ?? "N/A"} +
+
+
Verify
+ {guide.verifyCmd ?? "N/A"} +
+
+ +

{guide.note}

+
+ ); +} + function getErrorMessage(error: unknown): string { if (error instanceof Error && error.message.trim().length > 0) { return error.message; @@ -617,6 +687,9 @@ function SettingsRouteView() { password: settings.openclawPassword || undefined, }); setOpenclawTestResult(result); + if (result.success) { + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + } } catch (err) { setOpenclawTestResult({ success: false, @@ -627,7 +700,7 @@ function SettingsRouteView() { } finally { setOpenclawTestLoading(false); } - }, [openclawTestLoading, settings.openclawGatewayUrl, settings.openclawPassword]); + }, [openclawTestLoading, queryClient, settings.openclawGatewayUrl, settings.openclawPassword]); const handleCopyOpenclawDebugReport = useCallback(() => { if (!openclawTestResult) return; @@ -1321,14 +1394,39 @@ function SettingsRouteView() { status={`${selectableProviders.length} provider${selectableProviders.length === 1 ? "" : "s"} currently selectable`} >
- {SETTINGS_AUTH_PROVIDER_ORDER.map((provider) => ( - status.provider === provider) ?? null} - openclawGatewayUrl={settings.openclawGatewayUrl} - /> - ))} + {SETTINGS_AUTH_PROVIDER_ORDER.flatMap((provider) => { + const card = ( + status.provider === provider) ?? null + } + openclawGatewayUrl={settings.openclawGatewayUrl} + /> + ); + if (provider === "codex") { + return [ + card, + , + , + ]; + } + return [card]; + })}
diff --git a/packages/contracts/src/codexConfig.ts b/packages/contracts/src/codexConfig.ts index bb100c1f..44f8d15a 100644 --- a/packages/contracts/src/codexConfig.ts +++ b/packages/contracts/src/codexConfig.ts @@ -12,9 +12,22 @@ export const ServerCodexModelProviderEntry = Schema.Struct({ }); export type ServerCodexModelProviderEntry = typeof ServerCodexModelProviderEntry.Type; +export const ServerCodexLocalBackendProbe = Schema.Struct({ + reachable: Schema.Boolean, + modelCount: Schema.optional(Schema.Number), +}); +export type ServerCodexLocalBackendProbe = typeof ServerCodexLocalBackendProbe.Type; + +export const ServerCodexDetectedLocalBackends = Schema.Struct({ + ollama: ServerCodexLocalBackendProbe, + lmstudio: ServerCodexLocalBackendProbe, +}); +export type ServerCodexDetectedLocalBackends = typeof ServerCodexDetectedLocalBackends.Type; + export const ServerCodexConfigSummary = Schema.Struct({ selectedModelProviderId: Schema.NullOr(TrimmedNonEmptyString), entries: Schema.Array(ServerCodexModelProviderEntry), parseError: Schema.NullOr(Schema.String), + detectedLocalBackends: Schema.optional(ServerCodexDetectedLocalBackends), }); export type ServerCodexConfigSummary = typeof ServerCodexConfigSummary.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8ea0d030..64d71f28 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -10,6 +10,7 @@ import { import { applyClaudePromptEffortPrefix, + CLAUDE_ULTRATHINK_THINKING_TOKENS, getEffectiveClaudeCodeEffort, getDefaultModel, getDefaultReasoningEffort, @@ -20,6 +21,7 @@ import { normalizeClaudeModelOptions, normalizeCodexModelOptions, normalizeModelSlug, + resolveClaudeUltrathinkSdkConfig, resolveReasoningEffortForProvider, resolveSelectableModel, resolveModelSlug, @@ -274,6 +276,61 @@ describe("getEffectiveClaudeCodeEffort", () => { }); }); +describe("resolveClaudeUltrathinkSdkConfig", () => { + it("passes non-ultrathink efforts through without a thinking budget bump", () => { + expect(resolveClaudeUltrathinkSdkConfig("claude-opus-4-7", "high", null)).toEqual({ + effort: "high", + maxThinkingTokens: undefined, + }); + expect(resolveClaudeUltrathinkSdkConfig("claude-sonnet-4-6", null, null)).toEqual({ + effort: null, + maxThinkingTokens: undefined, + }); + }); + + it("preserves user-configured maxThinkingTokens outside ultrathink", () => { + expect(resolveClaudeUltrathinkSdkConfig("claude-opus-4-7", "medium", 4096)).toEqual({ + effort: "medium", + maxThinkingTokens: 4096, + }); + }); + + it("maps ultrathink on Opus 4.7 to effort=max with the ultrathink thinking budget", () => { + expect(resolveClaudeUltrathinkSdkConfig("claude-opus-4-7", "ultrathink", null)).toEqual({ + effort: "max", + maxThinkingTokens: CLAUDE_ULTRATHINK_THINKING_TOKENS, + }); + }); + + it("maps ultrathink on Sonnet 4.6 to effort=high (no max available) with the bumped budget", () => { + expect(resolveClaudeUltrathinkSdkConfig("claude-sonnet-4-6", "ultrathink", null)).toEqual({ + effort: "high", + maxThinkingTokens: CLAUDE_ULTRATHINK_THINKING_TOKENS, + }); + }); + + it("maps ultrathink on models without adaptive reasoning (Haiku) to effort=null but still bumps the budget", () => { + expect(resolveClaudeUltrathinkSdkConfig("claude-haiku-4-5", "ultrathink", null)).toEqual({ + effort: null, + maxThinkingTokens: CLAUDE_ULTRATHINK_THINKING_TOKENS, + }); + }); + + it("preserves a user-provided thinking budget higher than the ultrathink default", () => { + expect(resolveClaudeUltrathinkSdkConfig("claude-opus-4-7", "ultrathink", 90000)).toEqual({ + effort: "max", + maxThinkingTokens: 90000, + }); + }); + + it("does not downgrade the thinking budget when the user configured a lower number under ultrathink", () => { + expect(resolveClaudeUltrathinkSdkConfig("claude-opus-4-7", "ultrathink", 10)).toEqual({ + effort: "max", + maxThinkingTokens: CLAUDE_ULTRATHINK_THINKING_TOKENS, + }); + }); +}); + describe("normalizeCodexModelOptions", () => { it("drops default-only codex options", () => { expect( diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index bcefb27b..c7d083a7 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -288,6 +288,46 @@ export function getEffectiveClaudeCodeEffort( return effort === "ultrathink" ? null : effort; } +/** + * When Ultrathink is selected, it must materially change the Claude Code SDK + * request — not just prepend "Ultrathink:" to the prompt. This helper collapses + * ultrathink into the highest supported SDK effort for the model ("max" for + * Opus 4.6/4.7, "high" for Sonnet 4.6 which has no max, null for models with + * no adaptive reasoning like Haiku) and boosts `maxThinkingTokens` to the + * ultrathink default, unless the user explicitly configured a higher budget. + */ +export const CLAUDE_ULTRATHINK_THINKING_TOKENS = 63999; + +export interface ClaudeUltrathinkSdkConfig { + effort: Exclude | null; + maxThinkingTokens: number | undefined; +} + +export function resolveClaudeUltrathinkSdkConfig( + model: string | null | undefined, + effort: ClaudeCodeEffort | null | undefined, + userMaxThinkingTokens: number | null | undefined, +): ClaudeUltrathinkSdkConfig { + if (effort !== "ultrathink") { + return { + effort: getEffectiveClaudeCodeEffort(effort), + maxThinkingTokens: + typeof userMaxThinkingTokens === "number" ? userMaxThinkingTokens : undefined, + }; + } + const topEffort: Exclude | null = supportsClaudeMaxEffort(model) + ? "max" + : supportsClaudeAdaptiveReasoning(model) + ? "high" + : null; + const maxThinkingTokens = + typeof userMaxThinkingTokens === "number" && + userMaxThinkingTokens > CLAUDE_ULTRATHINK_THINKING_TOKENS + ? userMaxThinkingTokens + : CLAUDE_ULTRATHINK_THINKING_TOKENS; + return { effort: topEffort, maxThinkingTokens }; +} + export function normalizeCodexModelOptions( modelOptions: CodexModelOptions | null | undefined, ): CodexModelOptions | undefined {