diff --git a/src/acp/built-in-adapter.test.ts b/src/acp/built-in-adapter.test.ts index c6424637b..5545323b1 100644 --- a/src/acp/built-in-adapter.test.ts +++ b/src/acp/built-in-adapter.test.ts @@ -19,6 +19,7 @@ import type { Model } from '@/types' import { createBuiltInAdapter, harnessSignature, + withThinkingSessionOverride, type BuiltInAdapterOptions, type ResolvedPiModel, } from './built-in-adapter' @@ -94,7 +95,7 @@ describe('harnessSignature', () => { it('changes when the openai-compat context window changes', () => { expect(harnessSignature(openaiCompat(), 'sys')).not.toBe( - harnessSignature(openaiCompat({ contextWindow: 200000 }), 'sys'), + harnessSignature(openaiCompat({ contextWindow: 200_000 }), 'sys'), ) }) @@ -103,6 +104,26 @@ describe('harnessSignature', () => { }) }) +describe('withThinkingSessionOverride', () => { + it('leaves resolved config alone when thinking is on or model is not thinking-capable', () => { + const base = openaiCompat({ reasoning: true }) + expect(withThinkingSessionOverride(base, { startWithReasoning: 1 }, true)).toBe(base) + expect(withThinkingSessionOverride(base, { startWithReasoning: 1 }, undefined)).toBe(base) + expect(withThinkingSessionOverride(base, { startWithReasoning: 0 }, false)).toBe(base) + }) + + it('forces thinking off for anthropic and openai-compat when the chip is off', () => { + expect(withThinkingSessionOverride(anthropic(), { startWithReasoning: 1 }, false)).toEqual({ + ...anthropic(), + thinkingLevel: 'off', + }) + expect(withThinkingSessionOverride(openaiCompat({ reasoning: true }), { startWithReasoning: 1 }, false)).toEqual({ + descriptor: { ...openaiCompat({ reasoning: true }).descriptor, reasoning: false }, + thinkingLevel: 'off', + }) + }) +}) + describe('createBuiltInAdapter persistent harness', () => { it('refreshes prompt/tools per send and rebuilds only for regenerate revision', async () => { const model = { diff --git a/src/acp/built-in-adapter.ts b/src/acp/built-in-adapter.ts index 6cf5c6bc0..41ba73abf 100644 --- a/src/acp/built-in-adapter.ts +++ b/src/acp/built-in-adapter.ts @@ -52,6 +52,7 @@ import { resolveOpenAiCompatConnection, type PreparedAiRequestConfig, } from '@/ai/fetch' +import { isThinkingDisabledForSend } from '@/ai/thinking-session' import type { Agent, AgentAdapter, AgentAdapterContext } from '@/types/acp' import type { Model, ModelProfile, ThunderboltUIMessage } from '@/types' import { extractLastUserText, resolveSkillTokenInstructions } from '@/skills/resolve-skill-system-messages' @@ -271,6 +272,28 @@ const resolvePiModel = ( } } +/** + * Honors the composer Thinking chip when the selected model can reason: chip + * off forces `thinkingLevel: 'off'` and clears openai-compat `reasoning`. + */ +export const withThinkingSessionOverride = ( + resolved: ResolvedPiModel, + model: Pick, + thinkingEnabled: boolean | undefined, +): ResolvedPiModel => { + if (!isThinkingDisabledForSend(model, thinkingEnabled)) { + return resolved + } + if (resolved.descriptor.kind === 'openai-compat') { + return { + ...resolved, + descriptor: { ...resolved.descriptor, reasoning: false }, + thinkingLevel: 'off', + } + } + return { ...resolved, thinkingLevel: 'off' } +} + /** Compact non-cryptographic fingerprint (FNV-1a) of a secret, so the harness * signature can detect an api-key change without embedding the plaintext key. */ const hashSecret = (value: string): string => { @@ -434,10 +457,11 @@ const fetchViaHarness = async ( reconnectClient: context.reconnectClient, httpClient: context.httpClient, }) - const resolved = resolvePiModel(agentCore, context, config.profile) - if (!resolved) { + const resolvedBase = resolvePiModel(agentCore, context, config.profile) + if (!resolvedBase) { return fallback() } + const resolved = withThinkingSessionOverride(resolvedBase, context.selectedModel, context.thinkingEnabled) const messages = parseMessages(init) const instructionBySlug = new Map(config.skills.map(({ name, instruction }) => [name, instruction])) @@ -507,6 +531,7 @@ export const createBuiltInAdapter = (agent: Agent, options: BuiltInAdapterOption reconnectClient: context.reconnectClient, httpClient: context.httpClient, getProxyFetch: context.getProxyFetch, + thinkingEnabled: context.thinkingEnabled, }) // Route tool-capable Pi-serviceable models (anthropic + the OpenAI-wire family) diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index b7ddacfb8..e1049422e 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -12,6 +12,11 @@ import { isFinalStep, shouldRetry, } from '@/ai/step-logic' +import { + isThinkingDisabledForSend, + openaiCompatThinkingProviderOptions, + withThinkingDisabledForSend, +} from '@/ai/thinking-session' import { getAllSkills, getIntegrationStatus, getModel, getModelProfile, getSettings } from '@/dal' import { getMessage } from '@/dal/chat-messages' import { isWidgetSkillId } from '@/defaults/skills' @@ -196,6 +201,9 @@ type AiFetchStreamingResponseOptions = { * `ProxyFetchProvider` (`useProxyFetchGetter()`); non-React callers (eval * scripts) build a `proxyFetch` directly and wrap it in `() => fn`. */ getProxyFetch: () => FetchFn + /** Per-conversation Thinking chip override. `false` disables thinking for + * models that advertise it; omit/`true` keeps the model default. */ + thinkingEnabled?: boolean } /** @@ -678,6 +686,7 @@ export const aiFetchStreamingResponse = async ({ reconnectClient, httpClient, getProxyFetch, + thinkingEnabled, }: AiFetchStreamingResponseOptions) => { const options = init as RequestInit & { body: string } const body = JSON.parse(options.body) @@ -689,13 +698,15 @@ export const aiFetchStreamingResponse = async ({ // reach this function the user turn is already persisted. const db = getDb() - const { model, profile, supportsTools, sourceCollector, toolset, skills, mcpToolsMetadata, systemPrompt } = - await prepareAiRequestConfig({ - modelId, - mcpClients, - reconnectClient, - httpClient, - }) + const prepared = await prepareAiRequestConfig({ + modelId, + mcpClients, + reconnectClient, + httpClient, + }) + const thinkingDisabled = isThinkingDisabledForSend(prepared.model, thinkingEnabled) + const model = withThinkingDisabledForSend(prepared.model, thinkingEnabled) + const { profile, supportsTools, sourceCollector, toolset, skills, mcpToolsMetadata, systemPrompt } = prepared if (!supportsTools) { console.log('Model does not support tools, skipping tool setup') } @@ -709,9 +720,18 @@ export const aiFetchStreamingResponse = async ({ providerId: model.provider, model: baseModel, middleware: [ + // Extract `...` from content when present. Do NOT pass + // `startWithReasoning` from `model.startWithReasoning` — that DB flag + // means “model can think / show Thinking chip”, while the middleware + // option means “treat all content as reasoning until ”. + // Ollama thinking models emit a native `reasoning` field and plain + // answer `content` with no closing tag; wiring the chip flag through + // here classifies the answer as reasoning forever and never emits + // reasoning-end (stuck spinner with the reply stuck in the muted + // Thinking pane). extractReasoningMiddleware({ tagName: 'think', - startWithReasoning: Boolean(model.startWithReasoning), + startWithReasoning: false, }), ], }) @@ -733,6 +753,8 @@ export const aiFetchStreamingResponse = async ({ // rather than relying solely on the profile — custom OpenAI models may not have a profile. ...(model.vendor === 'openai' && { systemMessageMode: 'developer' as const }), ...profile?.providerOptions, + // Thinking chip off → ask OpenAI-compat endpoints (incl. Ollama) for no reasoning. + ...openaiCompatThinkingProviderOptions(thinkingDisabled), } const providerOptions = Object.keys(rawOptions).length > 0 ? { [providerOptionsKey]: rawOptions } : undefined diff --git a/src/ai/thinking-session.test.ts b/src/ai/thinking-session.test.ts new file mode 100644 index 000000000..f6bed910d --- /dev/null +++ b/src/ai/thinking-session.test.ts @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { + isThinkingDisabledForSend, + openaiCompatThinkingProviderOptions, + withThinkingDisabledForSend, +} from './thinking-session' + +describe('isThinkingDisabledForSend', () => { + it('is true only for thinking-capable models with the chip off', () => { + expect(isThinkingDisabledForSend({ startWithReasoning: 1 }, false)).toBe(true) + expect(isThinkingDisabledForSend({ startWithReasoning: 1 }, true)).toBe(false) + expect(isThinkingDisabledForSend({ startWithReasoning: 1 }, undefined)).toBe(false) + expect(isThinkingDisabledForSend({ startWithReasoning: 0 }, false)).toBe(false) + }) +}) + +describe('withThinkingDisabledForSend', () => { + it('returns the same model reference when thinking stays on', () => { + const model = { startWithReasoning: 1 as const, name: 'qwen' } + expect(withThinkingDisabledForSend(model, true)).toBe(model) + expect(withThinkingDisabledForSend(model, undefined)).toBe(model) + }) + + it('returns a copy with startWithReasoning cleared when the chip is off', () => { + const model = { startWithReasoning: 1, name: 'qwen' } + const next = withThinkingDisabledForSend(model, false) + expect(next).not.toBe(model) + expect(next).toEqual({ startWithReasoning: 0, name: 'qwen' }) + expect(model.startWithReasoning).toBe(1) + }) +}) + +describe('openaiCompatThinkingProviderOptions', () => { + it('requests reasoningEffort none when the chip disabled thinking', () => { + expect(openaiCompatThinkingProviderOptions(true)).toEqual({ reasoningEffort: 'none' }) + }) + + it('adds no provider options when thinking stays on', () => { + expect(openaiCompatThinkingProviderOptions(false)).toEqual({}) + }) +}) diff --git a/src/ai/thinking-session.ts b/src/ai/thinking-session.ts new file mode 100644 index 000000000..efeb65851 --- /dev/null +++ b/src/ai/thinking-session.ts @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { Model } from '@/types' + +/** + * True when the selected model advertises thinking and the conversation chip + * has turned it off for this send. + */ +export const isThinkingDisabledForSend = ( + model: Pick, + thinkingEnabled: boolean | undefined, +): boolean => model.startWithReasoning === 1 && thinkingEnabled === false + +/** + * Returns a model view with thinking forced off for this send when the chip + * disabled it. Does not mutate the stored model row (capability stays on so + * the composer chip remains visible). + */ +export const withThinkingDisabledForSend = >( + model: T, + thinkingEnabled: boolean | undefined, +): T => { + if (!isThinkingDisabledForSend(model, thinkingEnabled)) { + return model + } + return { ...model, startWithReasoning: 0 } +} + +/** + * OpenAI-compat provider options for the Thinking chip. Chip off asks the + * endpoint (incl. Ollama) for no reasoning effort on this send. + */ +export const openaiCompatThinkingProviderOptions = ( + thinkingDisabled: boolean, +): { reasoningEffort: 'none' } | Record => (thinkingDisabled ? { reasoningEffort: 'none' } : {}) diff --git a/src/chats/chat-instance.ts b/src/chats/chat-instance.ts index cb6044c68..26605eb10 100644 --- a/src/chats/chat-instance.ts +++ b/src/chats/chat-instance.ts @@ -169,7 +169,7 @@ export const createAgentRoutingFetch = ( throw new Error('No session found') } - const { chatThread, selectedAgent, selectedModel } = session + const { chatThread, selectedAgent, selectedModel, thinkingEnabled } = session // Save the user message before invoking the adapter. This serves three // purposes that previously only the built-in pipeline got for free: @@ -226,12 +226,16 @@ export const createAgentRoutingFetch = ( ? undefined : (request: RequestPermissionRequest) => requestPermissionViaStore(id, selectedAgent.id, request) + // Per-conversation Thinking chip is applied in the legacy/Pi request + // path via `thinkingEnabled` — keep the stored model row as the source + // of capability (so the chip stays visible when toggled off). return adapter.fetch(init, { threadId: id, chatThread, acpSessionId: chatThread?.acpSessionId ?? null, saveMessages, selectedModel, + thinkingEnabled, mcpClients, reconnectClient, httpClient, diff --git a/src/chats/chat-store.ts b/src/chats/chat-store.ts index 592c24c34..57c2b4726 100644 --- a/src/chats/chat-store.ts +++ b/src/chats/chat-store.ts @@ -50,6 +50,11 @@ export type ChatSession = { retriesExhausted: boolean selectedAgent: Agent selectedModel: Model + /** + * Per-conversation override for models that advertise thinking + * (`startWithReasoning === 1`). `undefined` means “use the model default” (on). + */ + thinkingEnabled?: boolean triggerData: AutomationRun | null } @@ -249,7 +254,7 @@ export const useChatStore = create()((set, get) => ({ } const nextSessions = new Map(sessions) - nextSessions.set(id, { ...session, selectedModel: model }) + nextSessions.set(id, { ...session, selectedModel: model, thinkingEnabled: undefined }) set({ sessions: nextSessions }) diff --git a/src/components/chat/chat-prompt-input.tsx b/src/components/chat/chat-prompt-input.tsx index d47df7d94..969ba16e4 100644 --- a/src/components/chat/chat-prompt-input.tsx +++ b/src/components/chat/chat-prompt-input.tsx @@ -4,7 +4,7 @@ import { isAgentAvailable as isAgentAvailable_default } from '@/acp/agent-availability' import { preloadAgentConnection } from '@/acp/adapter-cache' -import { useCurrentChatSession } from '@/chats/chat-store' +import { useChatStore, useCurrentChatSession } from '@/chats/chat-store' import { usePendingQuotes, usePendingQuotesStore } from '@/chats/pending-quotes-store' import { useCreateItem } from '@/components/create-item/context' import { estimateTokensForText } from '@/ai/tokenizers' @@ -35,11 +35,12 @@ import { AlertCircle, Loader2, X } from 'lucide-react' import { type ClipboardEvent, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useLocation as useLocation_default, useNavigate as useNavigate_default } from 'react-router' import { ChatAddMenu } from './chat-add-menu' +import { ChatModelPicker } from './chat-model-picker' import { ChatSkillsBar } from './chat-skills-bar' +import { ThinkingChip } from './thinking-chip' import { ContextOverflowModal } from '../context-overflow-modal' import { ContextUsageIndicator } from '../context-usage-indicator' import { PromptInput } from '../ui/prompt-input' -import { ChatModelPicker } from './chat-model-picker' import { buildAttachmentPart } from '@/lib/attachments' import { buildQuotePart } from '@/lib/quotes' import { QuoteChip } from './quote-chip' @@ -177,8 +178,15 @@ export const ChatPromptInput = forwardRef state.updateSession) + const thinkingOn = thinkingEnabled !== false + const showThinkingChip = selectedModel.startWithReasoning === 1 + const toggleThinking = useCallback(() => { + updateSession(chatThreadId, { thinkingEnabled: !thinkingOn }) + }, [chatThreadId, thinkingOn, updateSession]) const { messages, status, stop, sendMessage } = useChat({ chat: chatInstance, experimental_throttle: messageBookkeepingThrottleMs, @@ -603,7 +611,13 @@ export const ChatPromptInput = forwardRef : undefined + const footerEndElements = + !isConnecting && !isConnectionError ? ( + <> + {showThinkingChip && } + + + ) : undefined const handleAddChipFromBar = useCallback( (skill: Skill) => { diff --git a/src/components/chat/reasoning-group-title.tsx b/src/components/chat/reasoning-group-title.tsx index 5372f5e19..886d23c12 100644 --- a/src/components/chat/reasoning-group-title.tsx +++ b/src/components/chat/reasoning-group-title.tsx @@ -34,30 +34,30 @@ const activeToolLabel = (tool: ToolOrDynamicToolUIPart, mcpTools?: UIMessageMeta export const ReasoningGroupTitle = ({ totalDuration, isGroupReasoning, tools, mcpTools }: ReasoningGroupTitleProps) => { const activeIndex = tools.length - 1 const activeTool = tools[activeIndex] - const loadingLabel = activeTool ? activeToolLabel(activeTool, mcpTools) : null + // While streaming with no tool yet, still show a label — an empty title next + // to the spinner reads as a hung chat (common for thinking-only turns). + const loadingLabel = activeTool ? activeToolLabel(activeTool, mcpTools) : 'Thinking' return (
{isGroupReasoning ? ( - loadingLabel ? ( - + - - {loadingLabel} - - - ) : null + {loadingLabel} + + ) : ( { // ReasoningDisplay should render the reasoning text when streaming // It might be in a specific container, so check the container expect(container.textContent).toContain('Let me think about this...') + // Thinking-only turns must keep a visible status label (empty title + spinner + // reads as a hung chat). + expect(screen.getByTestId('tool-status')).toHaveTextContent('Thinking') }) it('should not render ReasoningDisplay when hasTextPart is true', () => { diff --git a/src/components/chat/thinking-chip.test.tsx b/src/components/chat/thinking-chip.test.tsx new file mode 100644 index 000000000..cfbe9e257 --- /dev/null +++ b/src/components/chat/thinking-chip.test.tsx @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' + +import { ThinkingChip } from './thinking-chip' + +afterEach(() => { + cleanup() +}) + +describe('ThinkingChip', () => { + it('exposes pressed state and toggles on click', () => { + const onToggle = mock(() => undefined) + render() + + const button = screen.getByRole('button', { name: 'Thinking on' }) + expect(button.getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(button) + expect(onToggle).toHaveBeenCalledTimes(1) + }) + + it('labels the off state for assistive tech', () => { + render( undefined} />) + const button = screen.getByRole('button', { name: 'Thinking off' }) + expect(button.getAttribute('aria-pressed')).toBe('false') + }) +}) diff --git a/src/components/chat/thinking-chip.tsx b/src/components/chat/thinking-chip.tsx new file mode 100644 index 000000000..bf4db0464 --- /dev/null +++ b/src/components/chat/thinking-chip.tsx @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Brain } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +type ThinkingChipProps = { + enabled: boolean + onToggle: () => void +} + +/** + * Composer control for models that advertise thinking / chain-of-thought. + * Sits beside the model picker; pressed = thinking on for this conversation. + * + * Relies on `Tooltip` (which already wraps a provider) — no nested + * TooltipProvider here. + */ +export const ThinkingChip = ({ enabled, onToggle }: ThinkingChipProps) => ( + + + + + + {enabled ? 'Thinking enabled for this chat' : 'Thinking disabled for this chat'} + + +) diff --git a/src/settings/models/add-model-form.tsx b/src/settings/models/add-model-form.tsx index 18bc46042..0b2b09644 100644 --- a/src/settings/models/add-model-form.tsx +++ b/src/settings/models/add-model-form.tsx @@ -7,12 +7,13 @@ import type { UseFormReturn } from 'react-hook-form' import { Button } from '@/components/ui/button' import { Combobox, type ComboboxItem } from '@/components/ui/combobox' -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormFooter } from '@/components/ui/form-footer' import { Input } from '@/components/ui/input' import { ResponsiveModalCancel } from '@/components/ui/responsive-modal' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { StatusCard } from '@/components/ui/status-card' +import { Switch } from '@/components/ui/switch' import { useAutofocusOnMount } from '@/hooks/use-autofocus-on-mount' import type { Model } from '@/types' import { ConnectionTestSection } from './connection-test-section' @@ -226,6 +227,68 @@ export const AddModelForm = ({ )} /> )} + {(model || selectedModelId === 'custom') && ( + <> + ( + + Context window + + { + const raw = event.target.value + field.onChange(raw === '' ? null : Number(raw)) + }} + /> + + + Tokens of context this model can use. Prefills from the provider when known. + + + + )} + /> + ( + +
+ Tools + Allow this model to call agent tools. +
+ + + +
+ )} + /> + ( + +
+ Thinking + Model supports chain-of-thought / reasoning traces. +
+ + + +
+ )} + /> + + )} {!supportsTools && (model || selectedModelId === 'custom') && ( } diff --git a/src/settings/models/edit-model-form.tsx b/src/settings/models/edit-model-form.tsx index 8835bac17..e0b0b310f 100644 --- a/src/settings/models/edit-model-form.tsx +++ b/src/settings/models/edit-model-form.tsx @@ -6,11 +6,12 @@ import { X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Combobox } from '@/components/ui/combobox' -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormFooter } from '@/components/ui/form-footer' import { Input } from '@/components/ui/input' import { ResponsiveModalCancel } from '@/components/ui/responsive-modal' import { StatusCard } from '@/components/ui/status-card' +import { Switch } from '@/components/ui/switch' import type { Model } from '@/types' import { ConnectionTestSection } from './connection-test-section' import { useEditModelFormState, type EditModelSubmission } from './use-edit-model-form-state' @@ -116,6 +117,62 @@ export const EditModelForm = ({ model, onCancel, onSubmit, isPending, submitErro )} /> )} + ( + + Context window + + { + const raw = event.target.value + field.onChange(raw === '' ? null : Number(raw)) + }} + /> + + Tokens of context this model can use. + + + )} + /> + ( + +
+ Tools + Allow this model to call agent tools. +
+ + + +
+ )} + /> + ( + +
+ Thinking + Model supports chain-of-thought / reasoning traces. +
+ + + +
+ )} + /> { it('derives Thunderbolt choices from shipped defaults', () => { @@ -57,3 +65,82 @@ describe('model catalog policy', () => { }) }) }) + +describe('fetchModelsForProvider Ollama path', () => { + it('prefers /api/tags capabilities when the Custom URL looks like Ollama', async () => { + const getSpy = spyOn(http, 'get').mockImplementation((url: string) => { + if (String(url).includes('/api/tags')) { + return stubJsonResponse({ + models: [ + { + name: 'qwen2.5:14b', + details: { context_length: 32_768 }, + capabilities: ['completion', 'tools'], + }, + ], + }) + } + throw new Error(`unexpected catalog URL: ${url}`) + }) + + try { + const models = await fetchModelsForProvider({ + provider: 'custom', + url: 'http://localhost:11434/v1', + }) + expect(models).toEqual([ + { + id: 'qwen2.5:14b', + name: 'qwen2.5:14b', + supports_tools: true, + supports_thinking: false, + supports_vision: false, + context_window: 32_768, + }, + ]) + expect(getSpy.mock.calls.some((call) => String(call[0]).endsWith('/api/tags'))).toBe(true) + } finally { + getSpy.mockRestore() + } + }) + + it('falls back to /v1/models when /api/tags fails for an Ollama-like URL', async () => { + const getSpy = spyOn(http, 'get').mockImplementation((url: string) => { + if (String(url).includes('/api/tags')) { + throw new Error('connection refused') + } + return stubJsonResponse({ data: [{ id: 'fallback-model' }] }) + }) + + try { + const models = await fetchModelsForProvider({ + provider: 'custom', + url: 'http://localhost:11434/v1', + }) + expect(models.map((model) => model.id)).toEqual(['fallback-model']) + expect(getSpy.mock.calls.some((call) => String(call[0]).endsWith('/api/tags'))).toBe(true) + expect(getSpy.mock.calls.some((call) => String(call[0]).includes('/v1/models'))).toBe(true) + } finally { + getSpy.mockRestore() + } + }) + + it('falls back to /v1/models when /api/tags returns a non-Ollama body', async () => { + const getSpy = spyOn(http, 'get').mockImplementation((url: string) => { + if (String(url).includes('/api/tags')) { + return stubJsonResponse({ status: 'ok' }) + } + return stubJsonResponse({ data: [{ id: 'openai-compat-model' }] }) + }) + + try { + const models = await fetchModelsForProvider({ + provider: 'custom', + url: 'http://ollama:11434/v1', + }) + expect(models.map((model) => model.id)).toEqual(['openai-compat-model']) + } finally { + getSpy.mockRestore() + } + }) +}) diff --git a/src/settings/models/model-catalog.ts b/src/settings/models/model-catalog.ts index 538aba78c..f40bc325c 100644 --- a/src/settings/models/model-catalog.ts +++ b/src/settings/models/model-catalog.ts @@ -9,6 +9,7 @@ import { normalizeOpenAiBaseUrl } from '@/lib/openai-base-url' import type { Model } from '@/types' import { defaultModels } from '@shared/defaults/models' import { catalogRequiresApiKey } from './model-policy' +import { tryFetchOllamaCatalog } from './ollama-catalog' export type AvailableModel = { id: string @@ -17,6 +18,12 @@ export type AvailableModel = { owned_by?: string supports_tools?: boolean supported_parameters?: string[] + /** Max context tokens when the provider advertises one (e.g. Ollama `/api/tags`). */ + context_window?: number | null + /** True when the provider advertises thinking / chain-of-thought support. */ + supports_thinking?: boolean + /** True when the provider advertises multimodal / vision support. */ + supports_vision?: boolean } export type CatalogRequest = { @@ -119,6 +126,15 @@ export const fetchModelsForProvider = async ({ provider, apiKey, url }: CatalogR .sort((left, right) => left.id.localeCompare(right.id)) } + // Local Ollama (default Custom URL) exposes tools / thinking / vision / + // context_length on `/api/tags`. Prefer that over sparse `/v1/models`. + if (provider === 'custom') { + const ollamaCatalog = await tryFetchOllamaCatalog(url) + if (ollamaCatalog) { + return ollamaCatalog + } + } + const endpoint = resolveCatalogEndpoint({ provider, apiKey, url }) if (!endpoint) { return [] diff --git a/src/settings/models/model-detail.tsx b/src/settings/models/model-detail.tsx index 5079a6326..6ccc244eb 100644 --- a/src/settings/models/model-detail.tsx +++ b/src/settings/models/model-detail.tsx @@ -53,6 +53,17 @@ export const ModelDetail = ({ model, onEdit, onDelete, onReset, onClose }: Model

{model.url}

)} + +

+ {model.contextWindow ? model.contextWindow.toLocaleString() : 'Not set'} +

+
+ +

{model.toolUsage === 1 ? 'Enabled' : 'Disabled'}

+
+ +

{model.startWithReasoning === 1 ? 'Enabled' : 'Disabled'}

+
{!!model.isConfidential && (
diff --git a/src/settings/models/ollama-catalog.test.ts b/src/settings/models/ollama-catalog.test.ts new file mode 100644 index 000000000..eeb67637b --- /dev/null +++ b/src/settings/models/ollama-catalog.test.ts @@ -0,0 +1,115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, it } from 'bun:test' +import { + isLikelyOllamaBaseUrl, + mapOllamaTagToAvailableModel, + ollamaTagModelSchema, + ollamaTagsResponseSchema, + resolveOllamaOrigin, +} from './ollama-catalog' + +describe('resolveOllamaOrigin', () => { + it('strips the OpenAI-compat /v1 suffix', () => { + expect(resolveOllamaOrigin('http://localhost:11434/v1')).toBe('http://localhost:11434') + expect(resolveOllamaOrigin('http://localhost:11434/v1/')).toBe('http://localhost:11434') + }) + + it('adds /v1 before stripping when the user omitted it', () => { + expect(resolveOllamaOrigin('http://127.0.0.1:11434')).toBe('http://127.0.0.1:11434') + }) + + it('returns null for malformed URLs', () => { + expect(resolveOllamaOrigin('not a url')).toBeNull() + }) +}) + +describe('isLikelyOllamaBaseUrl', () => { + it('matches the default Ollama port', () => { + expect(isLikelyOllamaBaseUrl('http://localhost:11434/v1')).toBe(true) + expect(isLikelyOllamaBaseUrl('http://192.168.1.10:11434')).toBe(true) + }) + + it('matches ollama hostnames on other ports', () => { + expect(isLikelyOllamaBaseUrl('http://ollama.local/v1')).toBe(true) + }) + + it('rejects unrelated custom endpoints (no probe)', () => { + expect(isLikelyOllamaBaseUrl('http://localhost:1234/v1')).toBe(false) + expect(isLikelyOllamaBaseUrl('https://api.openai.com/v1')).toBe(false) + expect(isLikelyOllamaBaseUrl(undefined)).toBe(false) + }) + + it('does not match Ollama on an uncommon port without ollama in the host', () => { + // Deliberate heuristic gap: still works via /v1/models, just without rich caps. + expect(isLikelyOllamaBaseUrl('http://localhost:11435/v1')).toBe(false) + }) +}) + +describe('ollamaTagsResponseSchema', () => { + it('accepts a well-formed /api/tags payload', () => { + const parsed = ollamaTagsResponseSchema.safeParse({ + models: [ + { + name: 'qwen3:1.7b', + details: { context_length: 40_960 }, + capabilities: ['completion', 'tools', 'thinking'], + }, + ], + }) + expect(parsed.success).toBe(true) + }) + + it('rejects a missing models array', () => { + expect(ollamaTagsResponseSchema.safeParse({}).success).toBe(false) + expect(ollamaTagsResponseSchema.safeParse({ models: 'nope' }).success).toBe(false) + }) +}) + +describe('ollamaTagModelSchema', () => { + it('rejects entries without a name', () => { + expect(ollamaTagModelSchema.safeParse({ capabilities: ['tools'] }).success).toBe(false) + }) +}) + +describe('mapOllamaTagToAvailableModel', () => { + it('maps tools + context from a Qwen 2.5 style tag', () => { + expect( + mapOllamaTagToAvailableModel({ + name: 'qwen2.5:14b', + details: { context_length: 32_768, family: 'qwen2' }, + capabilities: ['completion', 'tools'], + }), + ).toEqual({ + id: 'qwen2.5:14b', + name: 'qwen2.5:14b', + supports_tools: true, + supports_thinking: false, + supports_vision: false, + context_window: 32_768, + }) + }) + + it('maps thinking models', () => { + const mapped = mapOllamaTagToAvailableModel({ + name: 'qwen3:1.7b', + details: { context_length: 40_960 }, + capabilities: ['completion', 'tools', 'thinking'], + }) + expect(mapped.supports_thinking).toBe(true) + expect(mapped.supports_tools).toBe(true) + expect(mapped.context_window).toBe(40_960) + }) + + it('maps vision without tools', () => { + const mapped = mapOllamaTagToAvailableModel({ + name: 'llava:7b', + capabilities: ['completion', 'vision'], + }) + expect(mapped.supports_vision).toBe(true) + expect(mapped.supports_tools).toBe(false) + expect(mapped.context_window).toBeNull() + }) +}) diff --git a/src/settings/models/ollama-catalog.ts b/src/settings/models/ollama-catalog.ts new file mode 100644 index 000000000..71d773cae --- /dev/null +++ b/src/settings/models/ollama-catalog.ts @@ -0,0 +1,144 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { z } from 'zod' + +import { fetch } from '@/lib/fetch' +import { http } from '@/lib/http' +import { normalizeOpenAiBaseUrl } from '@/lib/openai-base-url' +import type { AvailableModel } from './model-catalog' + +/** One row from Ollama `GET /api/tags` (validated per entry). */ +export const ollamaTagModelSchema = z.object({ + name: z.string().min(1), + model: z.string().optional(), + details: z + .object({ + context_length: z.number().positive().optional(), + family: z.string().optional(), + parameter_size: z.string().optional(), + }) + .optional(), + capabilities: z.array(z.string()).optional(), +}) + +export type OllamaTagModel = z.infer + +/** + * Envelope for `GET /api/tags`. Entries stay `unknown` so one malformed tag + * can be skipped without failing the whole catalog. + */ +export const ollamaTagsResponseSchema = z.object({ + models: z.array(z.unknown()), +}) + +/** + * Strips the OpenAI-compat `/v1` suffix so the native Ollama `/api/tags` + * route resolves against the same origin the user typed into the Custom URL + * field. + */ +export const resolveOllamaOrigin = (openAiCompatibleUrl: string): string | null => { + try { + const normalized = normalizeOpenAiBaseUrl(openAiCompatibleUrl) + const origin = normalized.replace(/\/v1$/i, '') + // Reject empty / scheme-only leftovers from malformed input. + const parsed = new URL(origin) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? origin : null + } catch { + return null + } +} + +/** + * Heuristic gate before probing `/api/tags`. + * + * Matches default Ollama port `11434`, or a hostname containing `ollama` + * (e.g. docker service `http://ollama/v1`). Intentionally incomplete: Ollama + * on an uncommon port/host still works via `/v1/models`, just without rich + * capability fields. That trade-off avoids an extra failed request for every + * non-Ollama Custom endpoint (llama.cpp, corporate OpenAI-compat gateways). + */ +export const isLikelyOllamaBaseUrl = (url: string | undefined): boolean => { + if (!url) { + return false + } + try { + const parsed = new URL(normalizeOpenAiBaseUrl(url)) + if (parsed.port === '11434') { + return true + } + return /\bollama\b/i.test(parsed.hostname) + } catch { + return false + } +} + +/** Maps Ollama capability strings + detail fields onto Thunderbolt catalog fields. */ +export const mapOllamaTagToAvailableModel = (tag: OllamaTagModel): AvailableModel => { + const capabilities = new Set((tag.capabilities ?? []).map((capability) => capability.toLowerCase())) + const contextWindow = tag.details?.context_length + return { + id: tag.name, + name: tag.name, + supports_tools: capabilities.has('tools'), + supports_thinking: capabilities.has('thinking'), + supports_vision: capabilities.has('vision'), + context_window: typeof contextWindow === 'number' && contextWindow > 0 ? contextWindow : null, + } +} + +/** + * Fetches the native Ollama model list. + * + * Returns `null` when the probe should soft-fail (network error, non-2xx via + * http client, or body that is not Ollama-shaped) so callers fall back to + * `/v1/models`. Soft-fail is intentional for an optional capability probe — + * loud throw would break Custom-provider add flows for every non-Ollama URL + * that still matched the heuristic. + */ +export const fetchOllamaTagsCatalog = async (origin: string): Promise => { + try { + const raw: unknown = await http.get(`${origin}/api/tags`, { fetch }).json() + const envelope = ollamaTagsResponseSchema.safeParse(raw) + if (!envelope.success) { + console.error('Ollama /api/tags response failed schema validation:', envelope.error) + return null + } + + const models: AvailableModel[] = [] + for (const entry of envelope.data.models) { + const tag = ollamaTagModelSchema.safeParse(entry) + if (!tag.success) { + console.error('Skipping malformed Ollama tag entry:', tag.error) + continue + } + models.push(mapOllamaTagToAvailableModel(tag.data)) + } + + if (models.length === 0) { + return null + } + + return models.sort((left, right) => left.id.localeCompare(right.id)) + } catch (error) { + console.error('Ollama /api/tags probe failed:', error) + return null + } +} + +/** + * When the Custom URL looks like Ollama, prefer `/api/tags` (tools, thinking, + * vision, context length). Returns `null` when the probe should be skipped or + * failed so the OpenAI-compat catalog path can take over. + */ +export const tryFetchOllamaCatalog = async (url: string | undefined): Promise => { + if (!url || !isLikelyOllamaBaseUrl(url)) { + return null + } + const origin = resolveOllamaOrigin(url) + if (!origin) { + return null + } + return fetchOllamaTagsCatalog(origin) +} diff --git a/src/settings/models/use-add-model-form.test.tsx b/src/settings/models/use-add-model-form.test.tsx index 33b3d6c95..b713142b7 100644 --- a/src/settings/models/use-add-model-form.test.tsx +++ b/src/settings/models/use-add-model-form.test.tsx @@ -64,6 +64,9 @@ describe('useAddModelForm', () => { customModel: '', url: '', apiKey: '', + contextWindow: null, + toolUsage: true, + startWithReasoning: false, }) await getClock().runAllAsync() }) @@ -93,6 +96,9 @@ describe('useAddModelForm', () => { customModel: '', url: '', apiKey: '', + contextWindow: null, + toolUsage: true, + startWithReasoning: false, }) await getClock().runAllAsync() }) @@ -124,6 +130,9 @@ describe('useAddModelForm', () => { customModel: 'gpt-4-turbo-preview', url: '', apiKey: '', + contextWindow: null, + toolUsage: true, + startWithReasoning: false, }) await getClock().runAllAsync() }) @@ -133,6 +142,68 @@ describe('useAddModelForm', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('persists Ollama-advertised context, tools, and thinking flags on create', async () => { + const getSpy = spyOn(http, 'get').mockImplementation((url: string) => { + if (String(url).includes('/api/tags')) { + return stubJsonResponse({ + models: [ + { + name: 'qwen3:1.7b', + details: { context_length: 40_960 }, + capabilities: ['completion', 'tools', 'thinking'], + }, + ], + }) + } + return stubJsonResponse({ data: [] }) + }) + const onClose = mock(() => {}) + const { result } = renderHook(() => useAddModelForm({ isOpen: true, onClose }), { + wrapper: createTestProvider(), + }) + + try { + act(() => { + result.current.onProviderChange('custom') + }) + await act(async () => { + getClock().tick(500) + await getClock().runAllAsync() + }) + + act(() => { + result.current.onSelectModel('qwen3:1.7b') + }) + + expect(result.current.form.getValues('contextWindow')).toBe(40_960) + expect(result.current.form.getValues('toolUsage')).toBe(true) + expect(result.current.form.getValues('startWithReasoning')).toBe(true) + + await act(async () => { + result.current.onSubmit({ + provider: 'custom', + name: 'Qwen3 1.7b', + model: 'qwen3:1.7b', + customModel: '', + url: 'http://localhost:11434/v1', + apiKey: '', + contextWindow: 40_960, + toolUsage: true, + startWithReasoning: true, + }) + await getClock().runAllAsync() + }) + + const created = useChatStore.getState().models.find((candidate) => candidate.model === 'qwen3:1.7b') + expect(created?.contextWindow).toBe(40_960) + expect(created?.toolUsage).toBe(1) + expect(created?.startWithReasoning).toBe(1) + expect(onClose).toHaveBeenCalledTimes(1) + } finally { + getSpy.mockRestore() + } + }) + it('does not refetch an already requested catalog when visibility alone changes', async () => { const getSpy = spyOn(http, 'get').mockReturnValue(stubJsonResponse({ data: [{ id: 'gpt-test' }] })) const onClose = mock(() => {}) diff --git a/src/settings/models/use-add-model-form.ts b/src/settings/models/use-add-model-form.ts index 9619920b9..d6999c985 100644 --- a/src/settings/models/use-add-model-form.ts +++ b/src/settings/models/use-add-model-form.ts @@ -26,6 +26,10 @@ export const addModelFormSchema = z customModel: z.string().optional(), url: z.string().optional(), apiKey: z.string().optional(), + /** Max context tokens; null/empty means provider default / unknown. */ + contextWindow: z.union([z.number().int().positive(), z.null()]).optional(), + toolUsage: z.boolean(), + startWithReasoning: z.boolean(), }) .refine((data) => data.provider !== 'custom' || Boolean(data.url), { message: 'URL is required for Custom providers', @@ -41,6 +45,13 @@ export const addModelFormSchema = z export type AddModelFormValues = z.infer +/** Defaults applied when the catalog entry does not advertise a capability. */ +export const defaultCapabilityValues = { + contextWindow: null as number | null, + toolUsage: true, + startWithReasoning: false, +} + type AddModelState = { selectedModelId: string submitError: string | null @@ -110,6 +121,9 @@ export const useAddModelForm = ({ isOpen, onClose, onMutationStart }: UseAddMode customModel: '', url: '', apiKey: '', + contextWindow: defaultCapabilityValues.contextWindow, + toolUsage: defaultCapabilityValues.toolUsage, + startWithReasoning: defaultCapabilityValues.startWithReasoning, }, }) const provider = form.watch('provider') @@ -138,13 +152,16 @@ export const useAddModelForm = ({ isOpen, onClose, onMutationStart }: UseAddMode mutationFn: async (values: AddModelFormValues) => { await createModel(db, { id: uuidv7(), - ...values, + provider: values.provider, + name: values.name, + model: values.model, apiKey: values.apiKey || null, url: values.url || null, isSystem: 0, enabled: 1, - toolUsage: 1, - contextWindow: null, + toolUsage: values.toolUsage ? 1 : 0, + contextWindow: values.contextWindow ?? null, + startWithReasoning: values.startWithReasoning ? 1 : 0, }) return getAvailableModels(db) }, @@ -180,18 +197,35 @@ export const useAddModelForm = ({ isOpen, onClose, onMutationStart }: UseAddMode }) } + const applyCatalogCapabilities = (modelId: string) => { + const selected = catalog.models.find((candidate) => candidate.id === modelId) + form.setValue('contextWindow', selected?.context_window ?? defaultCapabilityValues.contextWindow, { + shouldValidate: false, + }) + form.setValue( + 'toolUsage', + selected?.supports_tools === undefined ? defaultCapabilityValues.toolUsage : selected.supports_tools, + { shouldValidate: false }, + ) + form.setValue('startWithReasoning', selected?.supports_thinking === true, { shouldValidate: false }) + } + const selectModel = (modelId: string) => { dispatch({ type: 'MODEL_SELECTED', modelId }) if (modelId === 'custom') { form.setValue('model', '', { shouldValidate: true }) form.setValue('customModel', '') form.setValue('name', '', { shouldValidate: true }) + form.setValue('contextWindow', defaultCapabilityValues.contextWindow, { shouldValidate: false }) + form.setValue('toolUsage', defaultCapabilityValues.toolUsage, { shouldValidate: false }) + form.setValue('startWithReasoning', defaultCapabilityValues.startWithReasoning, { shouldValidate: false }) return } form.setValue('model', modelId, { shouldValidate: true }) form.setValue('customModel', '') const selected = catalog.models.find((candidate) => candidate.id === modelId) form.setValue('name', selected?.name || generateModelName(modelId), { shouldValidate: true }) + applyCatalogCapabilities(modelId) } // Recomputes formState.isValid for the freshly blanked fields (the submit @@ -208,6 +242,12 @@ export const useAddModelForm = ({ isOpen, onClose, onMutationStart }: UseAddMode for (const field of ['name', 'model', 'customModel', 'apiKey'] as const) { form.setValue(field, '', { shouldValidate: false, shouldDirty: false }) } + form.setValue('contextWindow', defaultCapabilityValues.contextWindow, { shouldValidate: false, shouldDirty: false }) + form.setValue('toolUsage', defaultCapabilityValues.toolUsage, { shouldValidate: false, shouldDirty: false }) + form.setValue('startWithReasoning', defaultCapabilityValues.startWithReasoning, { + shouldValidate: false, + shouldDirty: false, + }) // Ollama's default local endpoint — the most common custom provider. form.setValue('url', nextProvider === 'custom' ? 'http://localhost:11434/v1' : '', { shouldValidate: false, @@ -220,10 +260,10 @@ export const useAddModelForm = ({ isOpen, onClose, onMutationStart }: UseAddMode const items = catalogToComboboxItems(catalog.models) return provider === 'thunderbolt' ? items : [...items, customModelItem] }, [catalog.models, provider]) - const supportsTools = - !selectedModelId || - selectedModelId === 'custom' || - catalog.models.find((candidate) => candidate.id === selectedModelId)?.supports_tools === true + // Prefer the form toggle once a model is chosen so the tools warning tracks + // user edits, not only the catalog advertisement. + const toolUsage = form.watch('toolUsage') + const supportsTools = !selectedModelId || selectedModelId === 'custom' || toolUsage return { form, diff --git a/src/settings/models/use-edit-model-form-state.ts b/src/settings/models/use-edit-model-form-state.ts index c497e05ba..7dbdf53b2 100644 --- a/src/settings/models/use-edit-model-form-state.ts +++ b/src/settings/models/use-edit-model-form-state.ts @@ -25,6 +25,10 @@ const editModelFormSchema = z.object({ model: z.string().min(1, { message: 'Model name is required.' }), url: z.string().optional(), apiKey: z.string().optional(), + /** Max context tokens; null means provider default / unknown. */ + contextWindow: z.union([z.number().int().positive(), z.null()]), + toolUsage: z.boolean(), + startWithReasoning: z.boolean(), }) const buildEditModelFormSchema = (provider: Model['provider']) => @@ -35,16 +39,26 @@ const buildEditModelFormSchema = (provider: Model['provider']) => type EditModelFormValues = z.infer -export type EditModelSubmission = Omit & { +export type EditModelSubmission = Omit & { id: string apiKey: string | null | undefined + toolUsage: 0 | 1 + startWithReasoning: 0 | 1 } /** Owns edit-model form, catalog, API-key policy, and connection-test orchestration. */ export const useEditModelFormState = (model: Model) => { const form = useForm({ resolver: zodResolver(buildEditModelFormSchema(model.provider)), - defaultValues: { name: model.name || '', model: model.model, url: model.url || '', apiKey: '' }, + defaultValues: { + name: model.name || '', + model: model.model, + url: model.url || '', + apiKey: '', + contextWindow: model.contextWindow ?? null, + toolUsage: model.toolUsage === 1, + startWithReasoning: model.startWithReasoning === 1, + }, }) const watchedModel = form.watch('model') const watchedUrl = form.watch('url') @@ -101,6 +115,22 @@ export const useEditModelFormState = (model: Model) => { void fetchCatalog(catalogRequest) } + const applyCatalogCapabilities = (modelId: string) => { + const selected = catalog.models.find((candidate) => candidate.id === modelId) + if (!selected) { + return + } + form.setValue('contextWindow', selected.context_window ?? null, { shouldDirty: true, shouldValidate: false }) + form.setValue('toolUsage', selected.supports_tools === undefined ? true : selected.supports_tools, { + shouldDirty: true, + shouldValidate: false, + }) + form.setValue('startWithReasoning', selected.supports_thinking === true, { + shouldDirty: true, + shouldValidate: false, + }) + } + const selectModel = (id: string) => { if (id === 'custom') { setIsCustomModel(true) @@ -108,6 +138,7 @@ export const useEditModelFormState = (model: Model) => { } setIsCustomModel(false) form.setValue('model', id, { shouldValidate: true, shouldDirty: true }) + applyCatalogCapabilities(id) } const changeUrl = (value: string) => { form.setValue('url', value, { shouldValidate: true, shouldDirty: true }) @@ -133,9 +164,14 @@ export const useEditModelFormState = (model: Model) => { }) } const submissionFor = (values: EditModelFormValues): EditModelSubmission => ({ - ...values, + name: values.name, + model: values.model, + url: values.url, apiKey: apiKeyEditValue(apiKeyEdit), id: model.id, + contextWindow: values.contextWindow, + toolUsage: values.toolUsage ? 1 : 0, + startWithReasoning: values.startWithReasoning ? 1 : 0, }) return { diff --git a/src/types/acp.ts b/src/types/acp.ts index 2a6b9d942..4a48a90e5 100644 --- a/src/types/acp.ts +++ b/src/types/acp.ts @@ -67,6 +67,12 @@ export type AgentAdapterContext = { acpSessionId: string | null saveMessages: SaveMessagesFunction selectedModel: Model + /** + * Per-conversation Thinking chip. Only meaningful when the selected model + * advertises thinking (`startWithReasoning === 1`). `false` disables thinking + * for this send; omit/`true` keeps the model default. + */ + thinkingEnabled?: boolean mcpClients: NamedMCPClient[] /** Reconnect a dropped MCP client at the `tools()` boundary; returns a fresh * client or null. Supplied by the MCP provider via the chat store. */