From 09e0f6108c96265758f74ae57870e77fe05e7536 Mon Sep 17 00:00:00 2001 From: Susheel Daswani Date: Mon, 27 Jul 2026 22:48:58 -0700 Subject: [PATCH 1/6] feat(models): surface Ollama capabilities and chat Thinking chip Co-authored-by: Cursor --- src/acp/built-in-adapter.test.ts | 23 +++- src/acp/built-in-adapter.ts | 28 +++- src/ai/fetch.ts | 23 +++- src/ai/thinking-session.test.ts | 15 +++ src/ai/thinking-session.ts | 14 ++ src/chats/chat-instance.ts | 6 +- src/chats/chat-store.ts | 7 +- src/components/chat/chat-prompt-input.tsx | 20 ++- src/components/chat/thinking-chip.tsx | 46 +++++++ src/settings/models/add-model-form.tsx | 65 ++++++++- src/settings/models/model-catalog.test.ts | 71 +++++++++- src/settings/models/model-catalog.ts | 16 +++ src/settings/models/model-detail.tsx | 11 ++ src/settings/models/ollama-catalog.test.ts | 95 ++++++++++++++ src/settings/models/ollama-catalog.ts | 123 ++++++++++++++++++ .../models/use-add-model-form.test.tsx | 71 ++++++++++ src/settings/models/use-add-model-form.ts | 54 +++++++- src/types/acp.ts | 6 + 18 files changed, 669 insertions(+), 25 deletions(-) create mode 100644 src/ai/thinking-session.test.ts create mode 100644 src/ai/thinking-session.ts create mode 100644 src/components/chat/thinking-chip.tsx create mode 100644 src/settings/models/ollama-catalog.test.ts create mode 100644 src/settings/models/ollama-catalog.ts 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..1daaab578 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,27 @@ 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 { + 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 +456,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 +530,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 2453ab56b..2136e200b 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -12,6 +12,7 @@ import { isFinalStep, shouldRetry, } from '@/ai/step-logic' +import { isThinkingDisabledForSend } from '@/ai/thinking-session' import { getAllSkills, getIntegrationStatus, getModel, getModelProfile, getSettings } from '@/dal' import { getMessage } from '@/dal/chat-messages' import { isWidgetSkillId } from '@/defaults/skills' @@ -195,6 +196,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 } /** @@ -677,6 +681,7 @@ export const aiFetchStreamingResponse = async ({ reconnectClient, httpClient, getProxyFetch, + thinkingEnabled, }: AiFetchStreamingResponseOptions) => { const options = init as RequestInit & { body: string } const body = JSON.parse(options.body) @@ -688,13 +693,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 = thinkingDisabled ? { ...prepared.model, startWithReasoning: 0 as const } : prepared.model + const { profile, supportsTools, sourceCollector, toolset, skills, mcpToolsMetadata, systemPrompt } = prepared if (!supportsTools) { console.log('Model does not support tools, skipping tool setup') } @@ -732,6 +739,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. + ...(thinkingDisabled && { reasoningEffort: 'none' as const }), } 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..5e960cd62 --- /dev/null +++ b/src/ai/thinking-session.test.ts @@ -0,0 +1,15 @@ +/* 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 } 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) + }) +}) diff --git a/src/ai/thinking-session.ts b/src/ai/thinking-session.ts new file mode 100644 index 000000000..e9d95b902 --- /dev/null +++ b/src/ai/thinking-session.ts @@ -0,0 +1,14 @@ +/* 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 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/thinking-chip.tsx b/src/components/chat/thinking-chip.tsx new file mode 100644 index 000000000..c77596b74 --- /dev/null +++ b/src/components/chat/thinking-chip.tsx @@ -0,0 +1,46 @@ +/* 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, TooltipProvider, 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. + */ +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/model-catalog.test.ts b/src/settings/models/model-catalog.test.ts index edf821b51..5e568d6a0 100644 --- a/src/settings/models/model-catalog.test.ts +++ b/src/settings/models/model-catalog.test.ts @@ -2,9 +2,17 @@ * 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 { describe, expect, it, spyOn } from 'bun:test' import { defaultModels } from '@shared/defaults/models' -import { canFetchCatalog, catalogRequestKey, isFetchableCatalogUrl, thunderboltModelCatalog } from './model-catalog' +import { http } from '@/lib/http' +import { stubJsonResponse } from '@/test-utils/http' +import { + canFetchCatalog, + catalogRequestKey, + fetchModelsForProvider, + isFetchableCatalogUrl, + thunderboltModelCatalog, +} from './model-catalog' describe('model catalog policy', () => { it('derives Thunderbolt choices from shipped defaults', () => { @@ -57,3 +65,62 @@ 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'], + }, + ], + }) as never + } + 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 the Custom URL is not Ollama-like', async () => { + const getSpy = spyOn(http, 'get').mockImplementation((url: string) => { + if (String(url).includes('/api/tags')) { + throw new Error('should not probe Ollama tags for llama.cpp') + } + return stubJsonResponse({ data: [{ id: 'local-model' }] }) as never + }) + + try { + const models = await fetchModelsForProvider({ + provider: 'custom', + url: 'http://localhost:1234/v1', + }) + expect(models.map((model) => model.id)).toEqual(['local-model']) + expect(getSpy.mock.calls.some((call) => String(call[0]).includes('/v1/models'))).toBe(true) + } 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..9c13e54be --- /dev/null +++ b/src/settings/models/ollama-catalog.test.ts @@ -0,0 +1,95 @@ +/* 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 { + contextLengthFromModelInfo, + isLikelyOllamaBaseUrl, + mapOllamaTagToAvailableModel, + 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', () => { + expect(isLikelyOllamaBaseUrl('http://localhost:1234/v1')).toBe(false) + expect(isLikelyOllamaBaseUrl('https://api.openai.com/v1')).toBe(false) + expect(isLikelyOllamaBaseUrl(undefined)).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() + }) +}) + +describe('contextLengthFromModelInfo', () => { + it('reads architecture-prefixed context_length keys', () => { + expect(contextLengthFromModelInfo({ 'qwen2.context_length': 32_768, 'general.architecture': 'qwen2' })).toBe(32_768) + expect(contextLengthFromModelInfo({ 'llama.context_length': 8192 })).toBe(8192) + }) + + it('returns null when absent', () => { + expect(contextLengthFromModelInfo(undefined)).toBeNull() + expect(contextLengthFromModelInfo({ 'general.architecture': 'qwen2' })).toBeNull() + }) +}) diff --git a/src/settings/models/ollama-catalog.ts b/src/settings/models/ollama-catalog.ts new file mode 100644 index 000000000..0b40ccda5 --- /dev/null +++ b/src/settings/models/ollama-catalog.ts @@ -0,0 +1,123 @@ +/* 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 { 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`. */ +export type OllamaTagModel = { + name: string + model?: string + details?: { + context_length?: number + family?: string + parameter_size?: string + } + capabilities?: string[] +} + +type OllamaTagsResponse = { + models?: OllamaTagModel[] +} + +/** + * Strips the OpenAI-compat `/v1` suffix so native Ollama routes + * (`/api/tags`, `/api/show`, `/api/version`) resolve 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: Custom URLs on the default Ollama port (or an explicit "ollama" + * host) are worth probing with `/api/tags` before falling back to sparse + * `/v1/models`. Avoids an extra failed request for every non-Ollama Custom + * endpoint (llama.cpp, corporate OpenAI-compat gateways, etc.). + */ +export const isLikelyOllamaBaseUrl = (url: string | undefined): boolean => { + if (!url) { + return false + } + try { + const parsed = new URL(normalizeOpenAiBaseUrl(url)) + if (parsed.port === '11434') { + return true + } + // Default HTTP port with an ollama-ish hostname (e.g. http://ollama:80/v1). + return /\bollama\b/i.test(parsed.hostname) + } catch { + return false + } +} + +/** Reads `*.context_length` from Ollama `model_info` (architecture-prefixed keys). */ +export const contextLengthFromModelInfo = (modelInfo: Record | undefined): number | null => { + if (!modelInfo) { + return null + } + for (const [key, value] of Object.entries(modelInfo)) { + if (key.endsWith('.context_length') && typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value + } + } + return null +} + +/** 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 endpoint is + * unreachable or not Ollama-shaped so callers can fall back to `/v1/models`. + */ +export const fetchOllamaTagsCatalog = async (origin: string): Promise => { + try { + const response = await http.get(`${origin}/api/tags`, { fetch }).json() + if (!Array.isArray(response.models)) { + return null + } + return response.models.map(mapOllamaTagToAvailableModel).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..50cbf7785 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'], + }, + ], + }) as never + } + return stubJsonResponse({ data: [] }) as never + }) + 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/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. */ From 8afe2c3a345b13f8fd6d38d175ab280fba5912a1 Mon Sep 17 00:00:00 2001 From: Susheel Daswani Date: Tue, 28 Jul 2026 19:21:31 -0700 Subject: [PATCH 2/6] refactor(models): tighten Ollama catalog and Thinking chip standards Zod-validate /api/tags at the network boundary, replace the startWithReasoning cast with withThinkingDisabledForSend, drop redundant TooltipProvider nesting, and document the Ollama URL probe heuristic trade-off. Co-authored-by: Cursor --- src/ai/fetch.ts | 4 +- src/ai/thinking-session.test.ts | 18 ++++- src/ai/thinking-session.ts | 18 +++++ src/components/chat/thinking-chip.tsx | 53 +++++++------- src/settings/models/ollama-catalog.test.ts | 35 ++++++++- src/settings/models/ollama-catalog.ts | 84 +++++++++++++++------- 6 files changed, 158 insertions(+), 54 deletions(-) diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index 2136e200b..f0e2e8686 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -12,7 +12,7 @@ import { isFinalStep, shouldRetry, } from '@/ai/step-logic' -import { isThinkingDisabledForSend } from '@/ai/thinking-session' +import { isThinkingDisabledForSend, withThinkingDisabledForSend } from '@/ai/thinking-session' import { getAllSkills, getIntegrationStatus, getModel, getModelProfile, getSettings } from '@/dal' import { getMessage } from '@/dal/chat-messages' import { isWidgetSkillId } from '@/defaults/skills' @@ -700,7 +700,7 @@ export const aiFetchStreamingResponse = async ({ httpClient, }) const thinkingDisabled = isThinkingDisabledForSend(prepared.model, thinkingEnabled) - const model = thinkingDisabled ? { ...prepared.model, startWithReasoning: 0 as const } : prepared.model + 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') diff --git a/src/ai/thinking-session.test.ts b/src/ai/thinking-session.test.ts index 5e960cd62..62a4e0dd7 100644 --- a/src/ai/thinking-session.test.ts +++ b/src/ai/thinking-session.test.ts @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { describe, expect, it } from 'bun:test' -import { isThinkingDisabledForSend } from './thinking-session' +import { isThinkingDisabledForSend, withThinkingDisabledForSend } from './thinking-session' describe('isThinkingDisabledForSend', () => { it('is true only for thinking-capable models with the chip off', () => { @@ -13,3 +13,19 @@ describe('isThinkingDisabledForSend', () => { 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) + }) +}) diff --git a/src/ai/thinking-session.ts b/src/ai/thinking-session.ts index e9d95b902..cfae5c966 100644 --- a/src/ai/thinking-session.ts +++ b/src/ai/thinking-session.ts @@ -4,6 +4,9 @@ import type { Model } from '@/types' +/** SQLite / Drizzle flag: reasoning off for this request only. */ +const REASONING_OFF = 0 + /** * True when the selected model advertises thinking and the conversation chip * has turned it off for this send. @@ -12,3 +15,18 @@ 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: REASONING_OFF } +} diff --git a/src/components/chat/thinking-chip.tsx b/src/components/chat/thinking-chip.tsx index c77596b74..bf4db0464 100644 --- a/src/components/chat/thinking-chip.tsx +++ b/src/components/chat/thinking-chip.tsx @@ -5,7 +5,7 @@ import { Brain } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' type ThinkingChipProps = { @@ -16,31 +16,32 @@ type ThinkingChipProps = { /** * 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'} - - - + + + + + + {enabled ? 'Thinking enabled for this chat' : 'Thinking disabled for this chat'} + + ) diff --git a/src/settings/models/ollama-catalog.test.ts b/src/settings/models/ollama-catalog.test.ts index 9c13e54be..0418fd71b 100644 --- a/src/settings/models/ollama-catalog.test.ts +++ b/src/settings/models/ollama-catalog.test.ts @@ -7,6 +7,8 @@ import { contextLengthFromModelInfo, isLikelyOllamaBaseUrl, mapOllamaTagToAvailableModel, + ollamaTagModelSchema, + ollamaTagsResponseSchema, resolveOllamaOrigin, } from './ollama-catalog' @@ -35,11 +37,42 @@ describe('isLikelyOllamaBaseUrl', () => { expect(isLikelyOllamaBaseUrl('http://ollama.local/v1')).toBe(true) }) - it('rejects unrelated custom endpoints', () => { + 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', () => { diff --git a/src/settings/models/ollama-catalog.ts b/src/settings/models/ollama-catalog.ts index 0b40ccda5..f06a0f20e 100644 --- a/src/settings/models/ollama-catalog.ts +++ b/src/settings/models/ollama-catalog.ts @@ -2,26 +2,38 @@ * 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`. */ -export type OllamaTagModel = { - name: string - model?: string - details?: { - context_length?: number - family?: string - parameter_size?: string - } - capabilities?: string[] -} +/** One row from Ollama `GET /api/tags` (validated). */ +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(), +}) -type OllamaTagsResponse = { - models?: OllamaTagModel[] -} +export type OllamaTagModel = z.infer + +/** + * Wire shape for `GET /api/tags`. Invalid / non-Ollama JSON fails parse so + * callers fall back to sparse OpenAI-compat `/v1/models`. + */ +export const ollamaTagsResponseSchema = z.object({ + models: z.array(ollamaTagModelSchema), +}) + +export type OllamaTagsResponse = z.infer /** * Strips the OpenAI-compat `/v1` suffix so native Ollama routes @@ -41,10 +53,13 @@ export const resolveOllamaOrigin = (openAiCompatibleUrl: string): string | null } /** - * Heuristic: Custom URLs on the default Ollama port (or an explicit "ollama" - * host) are worth probing with `/api/tags` before falling back to sparse - * `/v1/models`. Avoids an extra failed request for every non-Ollama Custom - * endpoint (llama.cpp, corporate OpenAI-compat gateways, etc.). + * 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) { @@ -55,7 +70,6 @@ export const isLikelyOllamaBaseUrl = (url: string | undefined): boolean => { if (parsed.port === '11434') { return true } - // Default HTTP port with an ollama-ish hostname (e.g. http://ollama:80/v1). return /\bollama\b/i.test(parsed.hostname) } catch { return false @@ -90,16 +104,38 @@ export const mapOllamaTagToAvailableModel = (tag: OllamaTagModel): AvailableMode } /** - * Fetches the native Ollama model list. Returns `null` when the endpoint is - * unreachable or not Ollama-shaped so callers can fall back to `/v1/models`. + * 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 response = await http.get(`${origin}/api/tags`, { fetch }).json() - if (!Array.isArray(response.models)) { + const raw: unknown = await http.get(`${origin}/api/tags`, { fetch }).json() + const envelope = z.object({ models: z.array(z.unknown()) }).safeParse(raw) + if (!envelope.success) { + console.error('Ollama /api/tags response failed schema validation:', envelope.error) return null } - return response.models.map(mapOllamaTagToAvailableModel).sort((left, right) => left.id.localeCompare(right.id)) + + 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 From d28ada0b316bfa30d8898d2b2779758de8f4ae41 Mon Sep 17 00:00:00 2001 From: Susheel Daswani Date: Tue, 28 Jul 2026 19:29:47 -0700 Subject: [PATCH 3/6] test(models): harden Ollama catalog failover and edit capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover /api/tags soft-fail → /v1/models, expose Thinking/tools/context on edit, and lock Thinking chip + reasoningEffort provider options in unit tests. Co-authored-by: Cursor --- src/ai/fetch.ts | 8 ++- src/ai/thinking-session.test.ts | 16 ++++- src/ai/thinking-session.ts | 8 +++ src/components/chat/thinking-chip.test.tsx | 31 ++++++++++ src/settings/models/edit-model-form.tsx | 59 ++++++++++++++++++- src/settings/models/model-catalog.test.ts | 30 ++++++++-- .../models/use-edit-model-form-state.ts | 42 ++++++++++++- 7 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 src/components/chat/thinking-chip.test.tsx diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index f0e2e8686..52063ae62 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -12,7 +12,11 @@ import { isFinalStep, shouldRetry, } from '@/ai/step-logic' -import { isThinkingDisabledForSend, withThinkingDisabledForSend } from '@/ai/thinking-session' +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' @@ -740,7 +744,7 @@ export const aiFetchStreamingResponse = async ({ ...(model.vendor === 'openai' && { systemMessageMode: 'developer' as const }), ...profile?.providerOptions, // Thinking chip off → ask OpenAI-compat endpoints (incl. Ollama) for no reasoning. - ...(thinkingDisabled && { reasoningEffort: 'none' as const }), + ...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 index 62a4e0dd7..f6bed910d 100644 --- a/src/ai/thinking-session.test.ts +++ b/src/ai/thinking-session.test.ts @@ -3,7 +3,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { describe, expect, it } from 'bun:test' -import { isThinkingDisabledForSend, withThinkingDisabledForSend } from './thinking-session' +import { + isThinkingDisabledForSend, + openaiCompatThinkingProviderOptions, + withThinkingDisabledForSend, +} from './thinking-session' describe('isThinkingDisabledForSend', () => { it('is true only for thinking-capable models with the chip off', () => { @@ -29,3 +33,13 @@ describe('withThinkingDisabledForSend', () => { 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 index cfae5c966..1cd126e45 100644 --- a/src/ai/thinking-session.ts +++ b/src/ai/thinking-session.ts @@ -30,3 +30,11 @@ export const withThinkingDisabledForSend = => (thinkingDisabled ? { reasoningEffort: 'none' } : {}) 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/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('falls back to /v1/models when the Custom URL is not Ollama-like', async () => { + 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('should not probe Ollama tags for llama.cpp') + throw new Error('connection refused') } - return stubJsonResponse({ data: [{ id: 'local-model' }] }) as never + return stubJsonResponse({ data: [{ id: 'fallback-model' }] }) as never }) try { const models = await fetchModelsForProvider({ provider: 'custom', - url: 'http://localhost:1234/v1', + url: 'http://localhost:11434/v1', }) - expect(models.map((model) => model.id)).toEqual(['local-model']) + 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' }) as never + } + return stubJsonResponse({ data: [{ id: 'openai-compat-model' }] }) as never + }) + + 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/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 { From c18ca40972f2285f3e433b3dee799f078ab08837 Mon Sep 17 00:00:00 2001 From: Susheel Daswani Date: Tue, 28 Jul 2026 19:36:12 -0700 Subject: [PATCH 4/6] =?UTF-8?q?refactor(models):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20dead=20catalog=20helpers,=20camelCase,=20DRY?= =?UTF-8?q?=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline reasoning-off flag, wire ollamaTagsResponseSchema into the fetch path, remove unused model_info helper, drop as-never test casts, and spread ResolvedPiModel consistently in withThinkingSessionOverride. Co-authored-by: Cursor --- src/acp/built-in-adapter.ts | 1 + src/ai/thinking-session.ts | 5 +--- src/settings/models/model-catalog.test.ts | 8 +++--- src/settings/models/ollama-catalog.test.ts | 13 ---------- src/settings/models/ollama-catalog.ts | 25 ++++--------------- .../models/use-add-model-form.test.tsx | 4 +-- 6 files changed, 13 insertions(+), 43 deletions(-) diff --git a/src/acp/built-in-adapter.ts b/src/acp/built-in-adapter.ts index 1daaab578..41ba73abf 100644 --- a/src/acp/built-in-adapter.ts +++ b/src/acp/built-in-adapter.ts @@ -286,6 +286,7 @@ export const withThinkingSessionOverride = ( } if (resolved.descriptor.kind === 'openai-compat') { return { + ...resolved, descriptor: { ...resolved.descriptor, reasoning: false }, thinkingLevel: 'off', } diff --git a/src/ai/thinking-session.ts b/src/ai/thinking-session.ts index 1cd126e45..efeb65851 100644 --- a/src/ai/thinking-session.ts +++ b/src/ai/thinking-session.ts @@ -4,9 +4,6 @@ import type { Model } from '@/types' -/** SQLite / Drizzle flag: reasoning off for this request only. */ -const REASONING_OFF = 0 - /** * True when the selected model advertises thinking and the conversation chip * has turned it off for this send. @@ -28,7 +25,7 @@ export const withThinkingDisabledForSend = { capabilities: ['completion', 'tools'], }, ], - }) as never + }) } throw new Error(`unexpected catalog URL: ${url}`) }) @@ -109,7 +109,7 @@ describe('fetchModelsForProvider Ollama path', () => { if (String(url).includes('/api/tags')) { throw new Error('connection refused') } - return stubJsonResponse({ data: [{ id: 'fallback-model' }] }) as never + return stubJsonResponse({ data: [{ id: 'fallback-model' }] }) }) try { @@ -128,9 +128,9 @@ describe('fetchModelsForProvider Ollama path', () => { 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' }) as never + return stubJsonResponse({ status: 'ok' }) } - return stubJsonResponse({ data: [{ id: 'openai-compat-model' }] }) as never + return stubJsonResponse({ data: [{ id: 'openai-compat-model' }] }) }) try { diff --git a/src/settings/models/ollama-catalog.test.ts b/src/settings/models/ollama-catalog.test.ts index 0418fd71b..eeb67637b 100644 --- a/src/settings/models/ollama-catalog.test.ts +++ b/src/settings/models/ollama-catalog.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from 'bun:test' import { - contextLengthFromModelInfo, isLikelyOllamaBaseUrl, mapOllamaTagToAvailableModel, ollamaTagModelSchema, @@ -114,15 +113,3 @@ describe('mapOllamaTagToAvailableModel', () => { expect(mapped.context_window).toBeNull() }) }) - -describe('contextLengthFromModelInfo', () => { - it('reads architecture-prefixed context_length keys', () => { - expect(contextLengthFromModelInfo({ 'qwen2.context_length': 32_768, 'general.architecture': 'qwen2' })).toBe(32_768) - expect(contextLengthFromModelInfo({ 'llama.context_length': 8192 })).toBe(8192) - }) - - it('returns null when absent', () => { - expect(contextLengthFromModelInfo(undefined)).toBeNull() - expect(contextLengthFromModelInfo({ 'general.architecture': 'qwen2' })).toBeNull() - }) -}) diff --git a/src/settings/models/ollama-catalog.ts b/src/settings/models/ollama-catalog.ts index f06a0f20e..b5eb4fd11 100644 --- a/src/settings/models/ollama-catalog.ts +++ b/src/settings/models/ollama-catalog.ts @@ -9,7 +9,7 @@ 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). */ +/** One row from Ollama `GET /api/tags` (validated per entry). */ export const ollamaTagModelSchema = z.object({ name: z.string().min(1), model: z.string().optional(), @@ -26,15 +26,13 @@ export const ollamaTagModelSchema = z.object({ export type OllamaTagModel = z.infer /** - * Wire shape for `GET /api/tags`. Invalid / non-Ollama JSON fails parse so - * callers fall back to sparse OpenAI-compat `/v1/models`. + * 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(ollamaTagModelSchema), + models: z.array(z.unknown()), }) -export type OllamaTagsResponse = z.infer - /** * Strips the OpenAI-compat `/v1` suffix so native Ollama routes * (`/api/tags`, `/api/show`, `/api/version`) resolve against the same origin @@ -76,19 +74,6 @@ export const isLikelyOllamaBaseUrl = (url: string | undefined): boolean => { } } -/** Reads `*.context_length` from Ollama `model_info` (architecture-prefixed keys). */ -export const contextLengthFromModelInfo = (modelInfo: Record | undefined): number | null => { - if (!modelInfo) { - return null - } - for (const [key, value] of Object.entries(modelInfo)) { - if (key.endsWith('.context_length') && typeof value === 'number' && Number.isFinite(value) && value > 0) { - return value - } - } - return null -} - /** 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())) @@ -115,7 +100,7 @@ export const mapOllamaTagToAvailableModel = (tag: OllamaTagModel): AvailableMode export const fetchOllamaTagsCatalog = async (origin: string): Promise => { try { const raw: unknown = await http.get(`${origin}/api/tags`, { fetch }).json() - const envelope = z.object({ models: z.array(z.unknown()) }).safeParse(raw) + const envelope = ollamaTagsResponseSchema.safeParse(raw) if (!envelope.success) { console.error('Ollama /api/tags response failed schema validation:', envelope.error) return null diff --git a/src/settings/models/use-add-model-form.test.tsx b/src/settings/models/use-add-model-form.test.tsx index 50cbf7785..b713142b7 100644 --- a/src/settings/models/use-add-model-form.test.tsx +++ b/src/settings/models/use-add-model-form.test.tsx @@ -153,9 +153,9 @@ describe('useAddModelForm', () => { capabilities: ['completion', 'tools', 'thinking'], }, ], - }) as never + }) } - return stubJsonResponse({ data: [] }) as never + return stubJsonResponse({ data: [] }) }) const onClose = mock(() => {}) const { result } = renderHook(() => useAddModelForm({ isOpen: true, onClose }), { From 0ca2a34ac505c4397370873258d272f65c048585 Mon Sep 17 00:00:00 2001 From: Susheel Daswani Date: Tue, 28 Jul 2026 19:40:38 -0700 Subject: [PATCH 5/6] docs(models): trim resolveOllamaOrigin JSDoc to /api/tags only Co-authored-by: Cursor --- src/settings/models/ollama-catalog.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/settings/models/ollama-catalog.ts b/src/settings/models/ollama-catalog.ts index b5eb4fd11..71d773cae 100644 --- a/src/settings/models/ollama-catalog.ts +++ b/src/settings/models/ollama-catalog.ts @@ -34,9 +34,9 @@ export const ollamaTagsResponseSchema = z.object({ }) /** - * Strips the OpenAI-compat `/v1` suffix so native Ollama routes - * (`/api/tags`, `/api/show`, `/api/version`) resolve against the same origin - * the user typed into the Custom URL field. + * 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 { From 2249f9a8eb7d7dc4da6c5ae2dce3a9c3b7f36450 Mon Sep 17 00:00:00 2001 From: Susheel Daswani Date: Tue, 28 Jul 2026 21:50:34 -0700 Subject: [PATCH 6/6] fix: stop Thinking chip from trapping answers in reasoning middleware - model.startWithReasoning means show/enable thinking; middleware startWithReasoning means treat content as reasoning until - Ollama thinking models emit a native reasoning field and plain answer content with no closing tag, so wiring the chip flag through left the spinner stuck with the reply in the muted Thinking pane - show a Thinking label while streaming with no active tool Co-authored-by: Cursor --- src/ai/fetch.ts | 11 +++++- src/components/chat/reasoning-group-title.tsx | 36 +++++++++---------- src/components/chat/reasoning-group.test.tsx | 3 ++ 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index 52063ae62..9fbf889bf 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -719,9 +719,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, }), ], }) 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', () => {