diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 44358b45..a373af76 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -64,6 +64,7 @@ import { setMainWindowForDeepLinks, } from "./deeplink.js"; import { disconnectGoogleIfScopesStale } from "./oauth-handler.js"; +import { startModelsDevRefresh } from "@x/core/dist/models/models-dev.js"; // Captured as early as possible so it reflects actual process start. Used to // gate grace-eligible notifications (e.g. the burst of background-task @@ -381,6 +382,12 @@ app.whenReady().then(async () => { // Initialize all config files before UI can access them await initConfigs(); + // Warm the models.dev catalog cache (single writer; refreshed every 24h + // while the app runs). Every consumer — catalog listings, the reasoning + // capability gate — reads the on-disk cache only. Best-effort: failures + // leave any existing cache in use and never block boot. + startModelsDevRefresh(); + // PostHog identify() is idempotent — call it on every startup so existing // signed-in installs (and every cold start of v0.3.4+) get re-identified. // Otherwise main-process events stay anonymous until the user re-signs-in. diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 560fa90b..0ffe61bd 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -119,6 +119,7 @@ import { normalizeToolInput, normalizeToolOutput, parseAttachedFiles, + REASONING_EFFORT_LABELS, toToolState, } from '@/lib/chat-conversation' import { COMPOSIO_DISPLAY_NAMES as composioDisplayNames } from '@x/shared/src/composio.js' @@ -1430,6 +1431,9 @@ function App() { const chatViewStateByTabRef = useRef(chatViewStateByTab) const chatDraftsRef = useRef(new Map()) const selectedModelByTabRef = useRef(new Map()) + // Reasoning effort is per-tab, next-turn intent like the model selection — + // but unlike model it is never frozen on a run; it applies turn by turn. + const reasoningEffortByTabRef = useRef(new Map()) // Work directory is per-chat. Keyed by tab id; null/absent means none set. const [workDirByTab, setWorkDirByTab] = useState>({}) const workDirByTabRef = useRef(workDirByTab) @@ -2972,6 +2976,7 @@ function App() { // Per-message turn config. Composition inputs land in the system prompt // via the agent resolver; keep them session-sticky where possible so the // provider prefix cache survives across turns. + const reasoningEffort = reasoningEffortByTabRef.current.get(submitTabId) const sendConfig = { agent: { agentId, @@ -2989,6 +2994,7 @@ function App() { }, }, autoPermission: (permissionMode ?? 'manual') === 'auto', + ...(reasoningEffort ? { reasoningEffort } : {}), } const userMessageContextFor = (middlePane: Awaited>) => ({ currentDateTime: new Date().toISOString(), @@ -3336,6 +3342,7 @@ function App() { }) chatDraftsRef.current.delete(tabId) selectedModelByTabRef.current.delete(tabId) + reasoningEffortByTabRef.current.delete(tabId) chatScrollTopByTabRef.current.delete(tabId) setWorkDirByTab((prev) => { if (!(tabId in prev)) return prev @@ -6033,13 +6040,18 @@ function App() { if (isTurnUsageMessage(item)) { return ( -
+
+ {item.reasoningEffort && ( + + {REASONING_EFFORT_LABELS[item.reasoningEffort]} + + )}
) } @@ -6901,6 +6913,13 @@ function App() { selectedModelByTabRef.current.delete(tab.id) } }} + onReasoningEffortChange={(effort) => { + if (effort) { + reasoningEffortByTabRef.current.set(tab.id, effort) + } else { + reasoningEffortByTabRef.current.delete(tab.id) + } + }} workDir={workDirByTab[tab.id] ?? null} onWorkDirChange={(v) => setTabWorkDir(tab.id, v)} isRecording={isActive && isRecording} @@ -6990,6 +7009,13 @@ function App() { selectedModelByTabRef.current.delete(tabId) } }} + onReasoningEffortChangeForTab={(tabId, effort) => { + if (effort) { + reasoningEffortByTabRef.current.set(tabId, effort) + } else { + reasoningEffortByTabRef.current.delete(tabId) + } + }} workDirByTab={workDirByTab} onWorkDirChangeForTab={setTabWorkDir} codeSessionLocks={codeSessionLocks} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 223bfac8..15ddcd48 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -17,6 +17,7 @@ import { Globe, ImagePlus, LoaderIcon, + Brain, Lock, Mic, MoreHorizontal, @@ -110,6 +111,16 @@ export interface SelectedModel { model: string } +export type ReasoningEffortLevel = 'low' | 'medium' | 'high' + +// '' = auto (provider default). Ordered as shown in the picker. +const REASONING_EFFORT_OPTIONS: Array<{ value: '' | ReasoningEffortLevel; label: string; hint: string }> = [ + { value: '', label: 'Auto', hint: 'Provider default' }, + { value: 'low', label: 'Fast', hint: 'Minimal thinking' }, + { value: 'medium', label: 'Balanced', hint: 'Moderate thinking' }, + { value: 'high', label: 'Thorough', hint: 'Deep thinking, costs more' }, +] + export type PermissionMode = 'manual' | 'auto' function getSelectedModelDisplayName(model: string) { @@ -254,6 +265,11 @@ interface ChatInputInnerProps { callAvailable?: boolean /** Fired when the user picks a different model in the dropdown (only when no run exists yet). */ onSelectedModelChange?: (model: SelectedModel | null) => void + /** + * Fired when the user picks a reasoning effort (null = auto). Unlike model, + * effort is never frozen on a run — it applies per turn. + */ + onReasoningEffortChange?: (effort: ReasoningEffortLevel | null) => void /** Work directory for this chat (per-chat). Null when none is set. */ workDir?: string | null /** Fired when the user sets/changes/clears the work directory for this chat. */ @@ -290,6 +306,7 @@ function ChatInputInner({ onEndCall, callAvailable, onSelectedModelChange, + onReasoningEffortChange, workDir = null, onWorkDirChange, codeSessionLock = null, @@ -309,6 +326,10 @@ function ChatInputInner({ const [defaultModel, setDefaultModel] = useState(null) const loadModelConfigEpoch = useRef(0) const [lockedModel, setLockedModel] = useState(null) + // '' = auto. Per-model reasoning capability ("provider/model" → flag) from + // models:list; the effort control renders only for known-reasoning models. + const [reasoningEffort, setReasoningEffort] = useState<'' | ReasoningEffortLevel>('') + const [reasoningByKey, setReasoningByKey] = useState>({}) const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) @@ -431,12 +452,19 @@ function ChatInputInner({ // catalog (Ollama, OpenAI-compatible) fall back to the models saved in // config below. const catalog: Record = {} + const reasoningFlags: Record = {} try { const listResult = await window.ipc.invoke('models:list', null) for (const p of listResult.providers || []) { catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id) + for (const m of p.models || []) { + if (typeof m.reasoning === 'boolean') { + reasoningFlags[`${p.id}/${m.id}`] = m.reasoning + } + } } } catch { /* offline / no catalog — fall back to saved config below */ } + if (loadModelConfigEpoch.current === epoch) setReasoningByKey(reasoningFlags) if (isRowboatConnected) { for (const m of catalog['rowboat'] || []) push('rowboat', m) @@ -693,6 +721,30 @@ function ChatInputInner({ onSelectedModelChange?.({ provider: entry.provider, model: entry.model }) }, [pickerModels, lockedModel, onSelectedModelChange]) + // Reasoning effort applies to the model the next message will actually use: + // the run's frozen model once one exists, else the picker selection, else + // the app default. Only known-reasoning models show the control. + const effectiveModelKey = lockedModel + ? `${lockedModel.provider}/${lockedModel.model}` + : activeModelKey + || (defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : '') + const reasoningAvailable = reasoningByKey[effectiveModelKey] === true + + const handleReasoningEffortChange = useCallback((value: string) => { + const effort = value === 'low' || value === 'medium' || value === 'high' ? value : '' + setReasoningEffort(effort) + onReasoningEffortChange?.(effort === '' ? null : effort) + }, [onReasoningEffortChange]) + + // Switching to a model without reasoning support drops a stale selection — + // otherwise the next message would carry an effort the model rejects. + useEffect(() => { + if (!reasoningAvailable && reasoningEffort !== '') { + setReasoningEffort('') + onReasoningEffortChange?.(null) + } + }, [reasoningAvailable, reasoningEffort, onReasoningEffortChange]) + // Restore the tab draft when this input mounts. useEffect(() => { if (initialDraft) { @@ -1284,6 +1336,37 @@ function ChatInputInner({ )}
+ {reasoningAvailable && ( + + + + + + + + Reasoning effort — applies to your next message + + + + {REASONING_EFFORT_OPTIONS.map((option) => ( + + {option.label} + {option.hint} + + ))} + + + + )} {lockedModel ? ( @@ -1570,6 +1653,7 @@ export interface ChatInputWithMentionsProps { onEndCall?: () => void callAvailable?: boolean onSelectedModelChange?: (model: SelectedModel | null) => void + onReasoningEffortChange?: (effort: ReasoningEffortLevel | null) => void workDir?: string | null onWorkDirChange?: (value: string | null) => void /** Set when this chat is bound to a Code-section session — freezes workdir + agent. */ @@ -1603,6 +1687,7 @@ export function ChatInputWithMentions({ onEndCall, callAvailable, onSelectedModelChange, + onReasoningEffortChange, workDir, onWorkDirChange, codeSessionLock, @@ -1633,6 +1718,7 @@ export function ChatInputWithMentions({ onEndCall={onEndCall} callAvailable={callAvailable} onSelectedModelChange={onSelectedModelChange} + onReasoningEffortChange={onReasoningEffortChange} workDir={workDir} onWorkDirChange={onWorkDirChange} codeSessionLock={codeSessionLock} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 025a638d..2f013d96 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -38,7 +38,7 @@ import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-over import { defaultRemarkPlugins } from 'streamdown' import remarkBreaks from 'remark-breaks' import { type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type CallPreset, type PermissionMode, type StagedAttachment, type SelectedModel, type ReasoningEffortLevel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { useSidebar } from '@/components/ui/sidebar' import { wikiLabel } from '@/lib/wiki-links' @@ -63,6 +63,7 @@ import { normalizeToolInput, normalizeToolOutput, parseAttachedFiles, + REASONING_EFFORT_LABELS, toToolState, } from '@/lib/chat-conversation' import { matchBillingError } from '@/lib/billing-error' @@ -162,6 +163,7 @@ interface ChatSidebarProps { getInitialDraft?: (tabId: string) => string | undefined onDraftChangeForTab?: (tabId: string, text: string) => void onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void + onReasoningEffortChangeForTab?: (tabId: string, effort: ReasoningEffortLevel | null) => void workDirByTab?: Record /** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */ codeSessionLocks?: Record @@ -233,6 +235,7 @@ export function ChatSidebar({ getInitialDraft, onDraftChangeForTab, onSelectedModelChangeForTab, + onReasoningEffortChangeForTab, workDirByTab = {}, codeSessionLocks = {}, pinnedToCodeSession = null, @@ -524,13 +527,18 @@ export function ChatSidebar({ if (isTurnUsageMessage(item)) { return ( -
+
+ {item.reasoningEffort && ( + + {REASONING_EFFORT_LABELS[item.reasoningEffort]} + + )}
) } @@ -840,6 +848,7 @@ export function ChatSidebar({ initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} + onReasoningEffortChange={onReasoningEffortChangeForTab ? (effort) => onReasoningEffortChangeForTab(tab.id, effort) : undefined} workDir={workDirByTab[tab.id] ?? null} onWorkDirChange={onWorkDirChangeForTab ? (v) => onWorkDirChangeForTab(tab.id, v) : undefined} codeSessionLock={tabState.runId ? codeSessionLocks[tabState.runId] ?? null : null} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 6397ded2..b9185af3 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -53,11 +53,22 @@ export interface ErrorMessage { timestamp: number } +export type ReasoningEffortLevel = 'low' | 'medium' | 'high' + +// User-facing names for the canonical effort ladder ("auto" = absent). +export const REASONING_EFFORT_LABELS: Record = { + low: 'Fast', + medium: 'Balanced', + high: 'Thorough', +} + export interface TurnUsageMessage { id: string kind: 'turn-usage' usage: TokenUsage modelCallCount: number + // The turn's reasoning effort (from turn_created.config); absent = auto. + reasoningEffort?: ReasoningEffortLevel timestamp: number } diff --git a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts index f5d46295..bb697e60 100644 --- a/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts +++ b/apps/x/apps/renderer/src/lib/session-chat/turn-view.ts @@ -340,11 +340,13 @@ export function buildTurnConversation(state: TurnState): ConversationItem[] { } if (hasTokenUsage(state.usage)) { + const reasoningEffort = state.definition.config.reasoningEffort items.push({ id: `${turnId}:usage`, kind: 'turn-usage', usage: state.usage, modelCallCount: state.modelCalls.filter((call) => call.usage !== undefined).length, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), timestamp: ts(), }) } diff --git a/apps/x/packages/core/docs/turn-runtime-design.md b/apps/x/packages/core/docs/turn-runtime-design.md index c3ef6be9..d487aa90 100644 --- a/apps/x/packages/core/docs/turn-runtime-design.md +++ b/apps/x/packages/core/docs/turn-runtime-design.md @@ -597,6 +597,13 @@ bytes and the two views cannot diverge. Requests exclude credentials, auth headers, functions, model objects, and transport objects. +`parameters` holds only canonical, provider-agnostic generation knobs. The +model bridge whitelists what it forwards (`temperature`, `topP`, +`maxOutputTokens`, `providerOptions`) and maps `reasoningEffort` +(`"low" | "medium" | "high"`, stamped from `turn_created.config` onto every +call) into provider-specific options at invoke time — transport-only, like +prompt caching, so a persisted turn replays correctly on a different model. + The name `requested` is intentional. The event proves durable intent, not that the provider definitely received the request. diff --git a/apps/x/packages/core/src/agents/headless.ts b/apps/x/packages/core/src/agents/headless.ts index 0372d977..755076c3 100644 --- a/apps/x/packages/core/src/agents/headless.ts +++ b/apps/x/packages/core/src/agents/headless.ts @@ -36,6 +36,9 @@ export interface HeadlessAgentOptions { model?: string; provider?: string; maxModelCalls?: number; + // Canonical reasoning effort for this run; omitted = auto (provider + // default). Background callers that want cheap turns can pin "low". + reasoningEffort?: "low" | "medium" | "high"; signal?: AbortSignal; // Old waitForRunCompletion({ throwOnError: true }) semantics: `done` // rejects with HeadlessRunError unless the turn completes. @@ -157,6 +160,9 @@ export class HeadlessAgentRunner implements IHeadlessAgentRunner { ...(options.maxModelCalls === undefined ? {} : { maxModelCalls: options.maxModelCalls }), + ...(options.reasoningEffort === undefined + ? {} + : { reasoningEffort: options.reasoningEffort }), }, }); diff --git a/apps/x/packages/core/src/models/gateway.ts b/apps/x/packages/core/src/models/gateway.ts index ee14a7fa..bdaa7ab5 100644 --- a/apps/x/packages/core/src/models/gateway.ts +++ b/apps/x/packages/core/src/models/gateway.ts @@ -3,6 +3,7 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { getAccessToken } from '../auth/tokens.js'; import { getCurrentUseCase } from '../analytics/use_case.js'; import { API_URL } from '../config/env.js'; +import { annotateReasoningFlags } from './models-dev.js'; const authedFetch: typeof fetch = async (input, init) => { const token = await getAccessToken(); @@ -30,6 +31,7 @@ type ProviderSummary = { id: string; name?: string; release_date?: string; + reasoning?: boolean; }>; }; @@ -42,7 +44,9 @@ export async function listGatewayModels(): Promise<{ providers: ProviderSummary[ throw new Error(`Gateway /v1/models failed: ${response.status}`); } const body = await response.json() as { data: Array<{ id: string }> }; - const models = body.data.map((m) => ({ id: m.id })); + // The gateway returns bare "vendor/model" ids; the models.dev cache + // supplies the reasoning capability the composer's effort control needs. + const models = await annotateReasoningFlags(body.data.map((m) => ({ id: m.id }))); return { providers: [{ id: 'rowboat', diff --git a/apps/x/packages/core/src/models/models-dev.test.ts b/apps/x/packages/core/src/models/models-dev.test.ts new file mode 100644 index 00000000..9bb0bce5 --- /dev/null +++ b/apps/x/packages/core/src/models/models-dev.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildReasoningIndex, lookupReasoningFlag } from "./models-dev.js"; + +// Mirrors the real-world shapes that broke the join: models.dev spells +// versions with dashes ("claude-opus-4-8") while the gateway serves +// OpenRouter-style dotted ids ("anthropic/claude-opus-4.8") and bare +// unprefixed OpenAI ids ("gpt-5.4"). +const CATALOG = { + openai: { + name: "OpenAI", + models: { + "gpt-5.4": { id: "gpt-5.4", reasoning: true }, + "gpt-4.1": { id: "gpt-4.1", reasoning: false }, + }, + }, + anthropic: { + name: "Anthropic", + models: { + "claude-opus-4-8": { id: "claude-opus-4-8", reasoning: true }, + "claude-haiku-4-5": { id: "claude-haiku-4-5", reasoning: false }, + }, + }, + google: { + name: "Google", + models: { + "gemini-3.5-flash": { id: "gemini-3.5-flash", reasoning: true }, + }, + }, +} as never; + +describe("reasoning capability index", () => { + const index = buildReasoningIndex(CATALOG); + + it("joins dotted gateway ids against dashed catalog ids", () => { + expect(lookupReasoningFlag(index, "rowboat", "anthropic/claude-opus-4.8")).toBe(true); + expect(lookupReasoningFlag(index, "rowboat", "anthropic/claude-haiku-4.5")).toBe(false); + }); + + it("matches bare unprefixed ids on gateway flavors", () => { + expect(lookupReasoningFlag(index, "rowboat", "gpt-5.4")).toBe(true); + expect(lookupReasoningFlag(index, "rowboat", "gpt-4.1")).toBe(false); + }); + + it("matches strict flavors by their own namespace", () => { + expect(lookupReasoningFlag(index, "anthropic", "claude-opus-4-8")).toBe(true); + expect(lookupReasoningFlag(index, "openai", "gpt-5.4")).toBe(true); + expect(lookupReasoningFlag(index, "google", "gemini-3.5-flash")).toBe(true); + }); + + it("returns undefined for unknown models and unknown vendors", () => { + expect(lookupReasoningFlag(index, "rowboat", "mistralai/mistral-large")).toBeUndefined(); + expect(lookupReasoningFlag(index, "rowboat", "some-local-model")).toBeUndefined(); + expect(lookupReasoningFlag(index, "openai", "gpt-99")).toBeUndefined(); + }); + + it("drops bare ids that are ambiguous across vendors", () => { + const clashing = { + openai: { name: "OpenAI", models: { shared: { id: "shared", reasoning: true } } }, + anthropic: { name: "Anthropic", models: { shared: { id: "shared", reasoning: false } } }, + google: { name: "Google", models: {} }, + } as never; + const clashIndex = buildReasoningIndex(clashing); + expect(lookupReasoningFlag(clashIndex, "rowboat", "shared")).toBeUndefined(); + // Vendor-qualified lookups still work. + expect(lookupReasoningFlag(clashIndex, "rowboat", "openai/shared")).toBe(true); + expect(lookupReasoningFlag(clashIndex, "anthropic", "shared")).toBe(false); + }); +}); diff --git a/apps/x/packages/core/src/models/models-dev.ts b/apps/x/packages/core/src/models/models-dev.ts index 50e91411..f1e89834 100644 --- a/apps/x/packages/core/src/models/models-dev.ts +++ b/apps/x/packages/core/src/models/models-dev.ts @@ -66,6 +66,7 @@ const ModelsDevModel = z.object({ name: z.string().optional(), release_date: z.string().optional(), tool_call: z.boolean().optional(), + reasoning: z.boolean().optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(), }).passthrough(); @@ -84,6 +85,8 @@ type ProviderSummary = { id: string; name?: string; release_date?: string; + // Supports reasoning/extended thinking per models.dev; absent = unknown. + reasoning?: boolean; }>; }; @@ -110,13 +113,20 @@ async function writeCache(data: unknown): Promise { } async function fetchModelsDev(): Promise { - const response = await fetch("https://models.dev/api.json", { - headers: { "User-Agent": "Rowboat" }, - }); - if (!response.ok) { - throw new Error(`models.dev fetch failed: ${response.status}`); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch("https://models.dev/api.json", { + headers: { "User-Agent": "Rowboat" }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`models.dev fetch failed: ${response.status}`); + } + return await response.json(); + } finally { + clearTimeout(timeout); } - return response.json(); } function isCacheFresh(fetchedAt: string): boolean { @@ -124,31 +134,69 @@ function isCacheFresh(fetchedAt: string): boolean { return age < CACHE_TTL_MS; } -async function getModelsDevData(): Promise<{ data: z.infer; fetchedAt?: string }> { - const cached = await readCache(); - if (cached?.fetchedAt && isCacheFresh(cached.fetchedAt)) { - const parsed = ModelsDevResponse.safeParse(cached.data); - if (parsed.success) { - return { data: parsed.data, fetchedAt: cached.fetchedAt }; - } - } +// --------------------------------------------------------------------------- +// Single-writer refresh. The ONLY code path that touches the network. Main +// calls startModelsDevRefresh() once at app start; everything else in the +// process reads the on-disk cache. Failures are logged and swallowed — a +// stale or absent cache degrades capability-derived UI (reasoning chip, +// catalog listings), never chat. +// --------------------------------------------------------------------------- +let initialRefresh: Promise | null = null; +let refreshTimer: ReturnType | null = null; + +async function refreshCache(force: boolean): Promise { try { + if (!force) { + const cached = await readCache(); + if (cached?.fetchedAt && isCacheFresh(cached.fetchedAt)) return; + } const fresh = await fetchModelsDev(); const parsed = ModelsDevResponse.parse(fresh); await writeCache(parsed); - return { data: parsed, fetchedAt: new Date().toISOString() }; } catch (error) { - if (cached) { - const parsed = ModelsDevResponse.safeParse(cached.data); - if (parsed.success) { - return { data: parsed.data, fetchedAt: cached.fetchedAt }; - } - } - throw error; + console.warn( + "[models.dev] refresh failed (existing cache, if any, stays in use):", + error instanceof Error ? error.message : String(error), + ); } } +/** + * Kick off the best-effort cache warm-up: refresh now if the cache is + * missing or older than the TTL, then again every TTL while the app runs. + * Never throws, never blocks boot. Catalog-shaped readers await the first + * attempt (so a fresh install's first `models:list` sees the fetched data); + * the turn-start path never waits. + */ +export function startModelsDevRefresh(): void { + if (initialRefresh) return; + initialRefresh = refreshCache(false); + refreshTimer = setInterval(() => { + void refreshCache(true); + }, CACHE_TTL_MS); + refreshTimer.unref?.(); +} + +async function awaitInitialRefresh(): Promise { + if (initialRefresh) await initialRefresh; +} + +/** + * Cache-only read of the catalog. Waits for the startup refresh's first + * attempt (bounded by its 10s fetch timeout) so first-run consumers see the + * warmed cache; after that attempt settles the wait is free. Returns null + * when no usable cache exists. + */ +async function getModelsDevData(): Promise<{ data: z.infer; fetchedAt?: string } | null> { + await awaitInitialRefresh(); + const cached = await readCache(); + if (!cached) return null; + const parsed = ModelsDevResponse.safeParse(cached.data); + if (!parsed.success) return null; + return { data: parsed.data, fetchedAt: cached.fetchedAt }; +} + function scoreProvider(flavor: string, id: string, name: string): number { const normalizedId = id.toLowerCase(); const normalizedName = name.toLowerCase(); @@ -194,10 +242,11 @@ function normalizeModels(models: Record>) name: model.name, release_date: model.release_date, tool_call: model.tool_call, + reasoning: model.reasoning, status: model.status, })) .filter((model) => isStableModel(model) && supportsToolCall(model)) - .map(({ id, name, release_date }) => ({ id, name, release_date })); + .map(({ id, name, release_date, reasoning }) => ({ id, name, release_date, reasoning })); list.sort((a, b) => { const aDate = a.release_date ? Date.parse(a.release_date) : 0; @@ -208,7 +257,13 @@ function normalizeModels(models: Record>) } export async function listOnboardingModels(): Promise<{ providers: ProviderSummary[]; lastUpdated?: string }> { - const { data, fetchedAt } = await getModelsDevData(); + const catalog = await getModelsDevData(); + if (!catalog) { + // No cache yet (fresh install, models.dev unreachable): an empty catalog, + // not an error — the renderer falls back to models saved in config. + return { providers: [] }; + } + const { data, fetchedAt } = catalog; const providers: ProviderSummary[] = []; const flavors: Array<"openai" | "anthropic" | "google"> = ["openai", "anthropic", "google"]; @@ -225,12 +280,123 @@ export async function listOnboardingModels(): Promise<{ providers: ProviderSumma return { providers, lastUpdated: fetchedAt }; } +// Gateways spell model ids differently from models.dev: OpenRouter-style ids +// use dots in versions ("claude-opus-4.8") where models.dev uses dashes +// ("claude-opus-4-8"), and the rowboat gateway serves OpenAI models with no +// vendor prefix at all ("gpt-5.4"). Ids are joined case-insensitively with +// dots folded to dashes. +function normalizeModelId(id: string): string { + return id.toLowerCase().replace(/\./g, "-"); +} + +const REASONING_VENDORS = ["openai", "anthropic", "google"] as const; + +/** + * Pure reasoning-capability index over a parsed models.dev catalog. + * Keys: `${vendor}/${normalizedId}` always; bare `normalizedId` too, unless + * two vendors disagree on the flag for the same bare id (then ambiguous ids + * are dropped rather than guessed). + */ +export function buildReasoningIndex( + data: z.infer, +): Map { + const index = new Map(); + const ambiguous = new Set(); + for (const vendor of REASONING_VENDORS) { + const provider = pickProvider(data, vendor); + if (!provider) continue; + for (const [key, model] of Object.entries(provider.models)) { + if (typeof model.reasoning !== "boolean") continue; + const norm = normalizeModelId(model.id ?? key); + index.set(`${vendor}/${norm}`, model.reasoning); + if (ambiguous.has(norm)) continue; + const bare = index.get(norm); + if (bare === undefined) { + index.set(norm, model.reasoning); + } else if (bare !== model.reasoning) { + index.delete(norm); + ambiguous.add(norm); + } + } + } + return index; +} + +/** Pure lookup against buildReasoningIndex. undefined = unknown. */ +export function lookupReasoningFlag( + index: Map, + flavor: string, + modelId: string, +): boolean | undefined { + const slash = modelId.indexOf("/"); + if (slash > 0) { + const vendor = modelId.slice(0, slash).toLowerCase(); + if ((REASONING_VENDORS as readonly string[]).includes(vendor)) { + return index.get(`${vendor}/${normalizeModelId(modelId.slice(slash + 1))}`); + } + return undefined; + } + if ((REASONING_VENDORS as readonly string[]).includes(flavor)) { + return index.get(`${flavor}/${normalizeModelId(modelId)}`); + } + // Unprefixed id on a gateway-ish flavor (rowboat serves "gpt-5.4" bare): + // match by bare id across vendors. + return index.get(normalizeModelId(modelId)); +} + +async function readReasoningIndex(): Promise | null> { + try { + const cached = await readCache(); + if (!cached) return null; + const parsed = ModelsDevResponse.safeParse(cached.data); + if (!parsed.success) return null; + return buildReasoningIndex(parsed.data); + } catch { + return null; + } +} + +/** + * Whether a model supports reasoning/extended thinking, per the models.dev + * catalog. Reads ONLY the on-disk cache (stale is fine) — this sits on the + * turn-start path and must never block on the network. Returns undefined + * when the model or provider is unknown or no cache exists; callers treat + * unknown as "don't send reasoning parameters" (fail closed). + */ +export async function isReasoningModel( + flavor: string, + modelId: string, +): Promise { + const index = await readReasoningIndex(); + if (!index) return undefined; + return lookupReasoningFlag(index, flavor, modelId); +} + +/** + * Annotate gateway model ids ("vendor/model" or bare) with the models.dev + * reasoning flag. Reads the cache once for the whole batch; unknown ids keep + * `reasoning` absent (= unknown). Waits for the startup warm-up's first + * attempt (catalog-shaped path, unlike isReasoningModel on the turn path). + */ +export async function annotateReasoningFlags( + models: T[], +): Promise> { + await awaitInitialRefresh(); + const index = await readReasoningIndex(); + if (!index) return models; + return models.map((model) => { + const reasoning = lookupReasoningFlag(index, "rowboat", model.id); + return reasoning === undefined ? model : { ...model, reasoning }; + }); +} + export async function getChatModelIds( flavor: "openai" | "anthropic" | "google", ): Promise> { try { - const { data } = await getModelsDevData(); - const provider = pickProvider(data, flavor); + const catalog = await getModelsDevData(); + if (!catalog) return new Set(); + const provider = pickProvider(catalog.data, flavor); if (!provider) return new Set(); const ids = new Set(); for (const [id, model] of Object.entries(provider.models)) { diff --git a/apps/x/packages/core/src/models/reasoning.test.ts b/apps/x/packages/core/src/models/reasoning.test.ts new file mode 100644 index 00000000..ad6af975 --- /dev/null +++ b/apps/x/packages/core/src/models/reasoning.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { mapReasoningEffort, parseReasoningEffort } from "./reasoning.js"; + +describe("parseReasoningEffort", () => { + it("accepts only the canonical ladder", () => { + expect(parseReasoningEffort("low")).toBe("low"); + expect(parseReasoningEffort("medium")).toBe("medium"); + expect(parseReasoningEffort("high")).toBe("high"); + expect(parseReasoningEffort("xhigh")).toBeUndefined(); + expect(parseReasoningEffort(3)).toBeUndefined(); + expect(parseReasoningEffort(undefined)).toBeUndefined(); + }); +}); + +describe("mapReasoningEffort", () => { + it("maps OpenAI effort directly, gated on known reasoning support", () => { + expect(mapReasoningEffort("openai", "o4-mini", "high", true)).toEqual({ + providerOptions: { openai: { reasoningEffort: "high" } }, + }); + // Unknown or absent capability fails closed: gpt-4.1 400s on the param. + expect(mapReasoningEffort("openai", "gpt-4.1", "high", undefined)).toBeUndefined(); + expect(mapReasoningEffort("openai", "gpt-4.1", "high", false)).toBeUndefined(); + }); + + it("maps Anthropic to thinking budgets with an output-token floor", () => { + expect(mapReasoningEffort("anthropic", "claude-x", "low", true)).toEqual({ + providerOptions: { anthropic: { thinking: { type: "disabled" } } }, + }); + expect(mapReasoningEffort("anthropic", "claude-x", "medium", true)).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 8192 } }, + }, + minOutputTokens: 12288, + }); + expect(mapReasoningEffort("anthropic", "claude-x", "high", true)).toEqual({ + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens: 16384 } }, + }, + minOutputTokens: 20480, + }); + expect(mapReasoningEffort("anthropic", "claude-x", "high", undefined)).toBeUndefined(); + }); + + it("maps Gemini 3 to thinking levels, clamping Pro's missing medium", () => { + expect(mapReasoningEffort("google", "gemini-3.5-flash", "medium", true)).toEqual({ + providerOptions: { google: { thinkingConfig: { thinkingLevel: "medium" } } }, + }); + expect(mapReasoningEffort("google", "gemini-3.5-pro", "medium", true)).toBeUndefined(); + expect(mapReasoningEffort("google", "gemini-3.5-pro", "high", true)).toEqual({ + providerOptions: { google: { thinkingConfig: { thinkingLevel: "high" } } }, + }); + }); + + it("maps Gemini 2.5 to token budgets and skips unknown generations", () => { + expect(mapReasoningEffort("google", "gemini-2.5-flash", "low", true)).toEqual({ + providerOptions: { google: { thinkingConfig: { thinkingBudget: 2048 } } }, + }); + expect(mapReasoningEffort("google", "gemini-1.5-pro", "high", true)).toBeUndefined(); + }); + + it("maps OpenRouter-shaped flavors permissively (OpenRouter drops it for non-reasoning models)", () => { + expect(mapReasoningEffort("rowboat", "google/gemini-3.5-flash", "high", undefined)).toEqual({ + providerOptions: { openrouter: { reasoning: { effort: "high" } } }, + }); + expect(mapReasoningEffort("openrouter", "openai/o4-mini", "low", true)).toEqual({ + providerOptions: { openrouter: { reasoning: { effort: "low" } } }, + }); + expect(mapReasoningEffort("rowboat", "x/y", "high", false)).toBeUndefined(); + }); + + it("sends nothing for flavors without a safe parameter", () => { + expect(mapReasoningEffort("ollama", "gpt-oss", "high", true)).toBeUndefined(); + expect(mapReasoningEffort("openai-compatible", "my-vllm-model", "high", true)).toBeUndefined(); + expect(mapReasoningEffort("aigateway", "openai/o4-mini", "high", true)).toBeUndefined(); + }); +}); diff --git a/apps/x/packages/core/src/models/reasoning.ts b/apps/x/packages/core/src/models/reasoning.ts new file mode 100644 index 00000000..c85e09c3 --- /dev/null +++ b/apps/x/packages/core/src/models/reasoning.ts @@ -0,0 +1,101 @@ +import type { z } from "zod"; +import type { ReasoningEffort } from "@x/shared/dist/models.js"; +import type { JsonValue } from "@x/shared/dist/turns.js"; + +export type ReasoningEffortLevel = z.infer; + +export interface ReasoningRequestOptions { + providerOptions: Record>; + // Anthropic requires max_tokens to exceed the thinking budget; when the + // mapping enables a budget it also supplies this floor. The bridge sends + // max(caller value, floor) so an explicit caller value is never lowered. + minOutputTokens?: number; +} + +// Token budgets for providers that express effort as a thinking budget. +// Values follow the levels other assistants converged on (~8k balanced, +// ~16k thorough); "low" means thinking off where the provider allows it. +const ANTHROPIC_BUDGET = { medium: 8192, high: 16384 } as const; +const GEMINI_25_BUDGET = { low: 2048, medium: 8192, high: 16384 } as const; +const ANTHROPIC_OUTPUT_HEADROOM = 4096; + +export function parseReasoningEffort(value: unknown): ReasoningEffortLevel | undefined { + return value === "low" || value === "medium" || value === "high" + ? value + : undefined; +} + +/** + * Map the canonical reasoning effort to provider-specific request options. + * Transport-only, like prompt caching: persisted turn events carry only the + * canonical value; this translation happens at invoke time. + * + * Returns undefined when nothing should be sent — unsupported flavor, model + * not known to reason, or a level the model family cannot express. Unknown + * capability fails closed for strict flavors (OpenAI/Anthropic/Google reject + * reasoning parameters on non-reasoning models); OpenRouter-shaped flavors + * (openrouter, rowboat) are forgiving — OpenRouter drops the field for + * models that cannot reason — so they map unless the model is known-false. + * + * Ollama is deliberately absent: its `think` parameter is applied by the + * provider-level fetch rewrite (models/local.ts), and openai-compatible + * endpoints have no safe universal parameter. + */ +export function mapReasoningEffort( + flavor: string, + modelId: string, + effort: ReasoningEffortLevel, + supportsReasoning: boolean | undefined, +): ReasoningRequestOptions | undefined { + switch (flavor) { + case "openai": { + if (supportsReasoning !== true) return undefined; + return { providerOptions: { openai: { reasoningEffort: effort } } }; + } + case "anthropic": { + if (supportsReasoning !== true) return undefined; + if (effort === "low") { + return { providerOptions: { anthropic: { thinking: { type: "disabled" } } } }; + } + const budgetTokens = ANTHROPIC_BUDGET[effort]; + return { + providerOptions: { + anthropic: { thinking: { type: "enabled", budgetTokens } }, + }, + minOutputTokens: budgetTokens + ANTHROPIC_OUTPUT_HEADROOM, + }; + } + case "google": { + if (supportsReasoning !== true) return undefined; + const id = modelId.toLowerCase(); + if (id.includes("gemini-3")) { + // Gemini 3 Pro exposes only low/high thinking levels; its + // default is already deep, so "medium" sends nothing. + if (id.includes("pro") && effort === "medium") return undefined; + return { + providerOptions: { + google: { thinkingConfig: { thinkingLevel: effort } }, + }, + }; + } + if (id.includes("gemini-2.5")) { + return { + providerOptions: { + google: { + thinkingConfig: { thinkingBudget: GEMINI_25_BUDGET[effort] }, + }, + }, + }; + } + // Unknown Gemini generation: don't guess a dialect. + return undefined; + } + case "openrouter": + case "rowboat": { + if (supportsReasoning === false) return undefined; + return { providerOptions: { openrouter: { reasoning: { effort } } } }; + } + default: + return undefined; + } +} diff --git a/apps/x/packages/core/src/sessions/api.ts b/apps/x/packages/core/src/sessions/api.ts index 7f787b49..6353ab63 100644 --- a/apps/x/packages/core/src/sessions/api.ts +++ b/apps/x/packages/core/src/sessions/api.ts @@ -16,6 +16,7 @@ export interface SendMessageConfig { agent: z.infer; autoPermission?: boolean; maxModelCalls?: number; + reasoningEffort?: "low" | "medium" | "high"; } export interface ISessions { diff --git a/apps/x/packages/core/src/sessions/sessions.ts b/apps/x/packages/core/src/sessions/sessions.ts index d42640b1..1bf63df0 100644 --- a/apps/x/packages/core/src/sessions/sessions.ts +++ b/apps/x/packages/core/src/sessions/sessions.ts @@ -189,6 +189,9 @@ export class SessionsImpl implements ISessions { ...(config.maxModelCalls === undefined ? {} : { maxModelCalls: config.maxModelCalls }), + ...(config.reasoningEffort === undefined + ? {} + : { reasoningEffort: config.reasoningEffort }), }, }); diff --git a/apps/x/packages/core/src/turns/api.ts b/apps/x/packages/core/src/turns/api.ts index 55a60e2c..37b35d5a 100644 --- a/apps/x/packages/core/src/turns/api.ts +++ b/apps/x/packages/core/src/turns/api.ts @@ -20,6 +20,9 @@ export interface CreateTurnInput { autoPermission?: boolean; humanAvailable: boolean; maxModelCalls?: number; + // Canonical per-turn reasoning effort; omitted = auto (provider + // default, byte-identical requests to today). + reasoningEffort?: "low" | "medium" | "high"; }; } diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts index 8fbf1a3e..26de134c 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.test.ts @@ -138,6 +138,335 @@ describe("RealModelRegistry", () => { ]); }); + it("captures block-level provider metadata opaquely onto parts (Anthropic-style signatures)", async () => { + // Anthropic delivers the thinking signature on a reasoning-delta with + // an EMPTY text delta, and redacted thinking as metadata on + // reasoning-start. Both must land on the right reasoning part, and + // distinct blocks must stay distinct parts. + const registry = makeRegistry( + [ + { type: "reasoning-start" }, + { type: "reasoning-delta", text: "let me think" }, + { type: "reasoning-delta", text: "", providerMetadata: { anthropic: { signature: "sig-1" } } }, + { type: "reasoning-end" }, + { + type: "reasoning-start", + providerMetadata: { anthropic: { redactedData: "opaque-blob" } }, + }, + { type: "reasoning-end" }, + { type: "text-start" }, + { type: "text-delta", text: "answer" }, + { type: "text-end" }, + { type: "finish-step", finishReason: "stop", usage: {} }, + ], + [], + ); + const events = await collect(registry, request()); + const completed = events[events.length - 1]; + expect( + completed.type === "completed" ? completed.message.content : undefined, + ).toEqual([ + { + type: "reasoning", + text: "let me think", + providerOptions: { anthropic: { signature: "sig-1" } }, + }, + { + type: "reasoning", + text: "", + providerOptions: { anthropic: { redactedData: "opaque-blob" } }, + }, + { type: "text", text: "answer" }, + ]); + }); + + it("captures metadata on text and tool-call parts (Gemini-style thoughtSignatures)", async () => { + const registry = makeRegistry( + [ + { type: "text-start" }, + { type: "text-delta", text: "calling a tool" }, + { type: "text-end", providerMetadata: { google: { thoughtSignature: "ts-text" } } }, + { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + input: { x: 1 }, + providerMetadata: { google: { thoughtSignature: "ts-call" } }, + }, + { type: "finish-step", finishReason: "tool-calls", usage: {} }, + ], + [], + ); + const events = await collect(registry, request()); + const completed = events[events.length - 1]; + expect( + completed.type === "completed" ? completed.message.content : undefined, + ).toEqual([ + { + type: "text", + text: "calling a tool", + providerOptions: { google: { thoughtSignature: "ts-text" } }, + }, + { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + arguments: { x: 1 }, + providerOptions: { google: { thoughtSignature: "ts-call" } }, + }, + ]); + }); + + it("merges metadata from multiple events of one block, later fields winning", async () => { + const registry = makeRegistry( + [ + { type: "reasoning-start", providerMetadata: { openai: { itemId: "r-1" } } }, + { type: "reasoning-delta", text: "…" }, + { + type: "reasoning-end", + providerMetadata: { openai: { itemId: "r-1", reasoningEncryptedContent: "enc" } }, + }, + { type: "finish-step", finishReason: "stop", usage: {} }, + ], + [], + ); + const events = await collect(registry, request()); + const completed = events[events.length - 1]; + expect( + completed.type === "completed" ? completed.message.content : undefined, + ).toEqual([ + { + type: "reasoning", + text: "…", + providerOptions: { openai: { itemId: "r-1", reasoningEncryptedContent: "enc" } }, + }, + ]); + }); + + it("attaches finish-step metadata as message-level providerOptions (OpenRouter signed reasoning)", async () => { + // OpenRouter streams per-delta reasoning_details FRAGMENTS on + // reasoning events, but puts the fully accumulated, signed array on + // the finish event's providerMetadata — and its read-back gives + // message-level reasoning_details precedence over per-part ones. + // The message-level attachment is what round-trips thinking + // signatures through tool loops (Bedrock rejects unsigned blocks). + const signedDetails = [ + { + type: "reasoning.text", + text: "full thought", + format: "anthropic-claude-v1", + index: 0, + signature: "sig-full", + }, + ]; + const registry = makeRegistry( + [ + { type: "reasoning-start" }, + { + type: "reasoning-delta", + text: "full ", + providerMetadata: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "full " }] } }, + }, + { + type: "reasoning-delta", + text: "thought", + providerMetadata: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "thought" }] } }, + }, + { type: "reasoning-end" }, + { type: "tool-call", toolCallId: "tc1", toolName: "echo", input: {} }, + { + type: "finish-step", + finishReason: "tool-calls", + usage: {}, + providerMetadata: { openrouter: { reasoning_details: signedDetails, usage: { cost: 1 } } }, + }, + ], + [], + ); + const events = await collect(registry, request()); + const completed = events[events.length - 1]; + expect( + completed.type === "completed" ? completed.message.providerOptions : undefined, + ).toEqual({ + openrouter: { reasoning_details: signedDetails, usage: { cost: 1 } }, + }); + }); + + it("echoes message-level providerOptions through encodeMessages", async () => { + const registry = makeRegistry([], []); + const model = await registry.resolve({ provider: "openai", model: "gpt-test" }); + const encoded = model.encodeMessages([ + { + role: "assistant", + content: [{ type: "text", text: "done" }], + providerOptions: { openrouter: { reasoning_details: [{ type: "reasoning.text", text: "t", signature: "s" }] } }, + }, + ] as never) as Array<{ role: string; providerOptions?: unknown }>; + expect(encoded[0].providerOptions).toEqual({ + openrouter: { reasoning_details: [{ type: "reasoning.text", text: "t", signature: "s" }] }, + }); + }); + + it("drops empty blocks that carry no metadata", async () => { + const registry = makeRegistry( + [ + { type: "reasoning-start" }, + { type: "reasoning-end" }, + { type: "text-start" }, + { type: "text-delta", text: "hi" }, + { type: "text-end" }, + { type: "finish-step", finishReason: "stop", usage: {} }, + ], + [], + ); + const events = await collect(registry, request()); + const completed = events[events.length - 1]; + expect( + completed.type === "completed" ? completed.message.content : undefined, + ).toEqual([{ type: "text", text: "hi" }]); + }); + + it("round-trips part-level providerOptions through encodeMessages", async () => { + const registry = makeRegistry([], []); + const model = await registry.resolve({ provider: "openai", model: "gpt-test" }); + const encoded = model.encodeMessages([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thought", + providerOptions: { anthropic: { signature: "sig-1" } }, + }, + { + type: "tool-call", + toolCallId: "tc1", + toolName: "echo", + arguments: { x: 1 }, + providerOptions: { google: { thoughtSignature: "ts-call" } }, + }, + ], + }, + ] as never) as Array<{ role: string; content: Array> }>; + + expect(encoded[0].content[0]).toMatchObject({ + type: "reasoning", + text: "thought", + providerOptions: { anthropic: { signature: "sig-1" } }, + }); + expect(encoded[0].content[1]).toMatchObject({ + type: "tool-call", + toolCallId: "tc1", + input: { x: 1 }, + providerOptions: { google: { thoughtSignature: "ts-call" } }, + }); + }); + + describe("reasoning effort mapping", () => { + function makeFlavorRegistry( + flavor: string, + supportsReasoning: boolean | undefined, + capture: InvokerOptions[], + ) { + const fakeModel = { modelId: "m" } as unknown as LanguageModel; + return new RealModelRegistry({ + resolveProvider: async () => ({ flavor }) as never, + createProviderImpl: (() => ({ + languageModel: () => fakeModel, + })) as never, + reasoningSupport: async () => supportsReasoning, + invoke: (options) => { + capture.push(options); + return { + fullStream: (async function* () { + yield { type: "finish-step", finishReason: "stop", usage: {} }; + })(), + }; + }, + }); + } + + async function invokeWith( + flavor: string, + model: string, + supportsReasoning: boolean | undefined, + parameters: Record, + ): Promise { + const capture: InvokerOptions[] = []; + const registry = makeFlavorRegistry(flavor, supportsReasoning, capture); + const resolved = await registry.resolve({ provider: flavor, model }); + for await (const event of resolved.stream( + request({ parameters: parameters as never }), + )) { + void event; + } + return capture[0]; + } + + it("maps a persisted canonical effort to Anthropic thinking options", async () => { + const options = await invokeWith("anthropic", "claude-x", true, { + reasoningEffort: "medium", + }); + expect(options.providerOptions).toEqual({ + anthropic: { thinking: { type: "enabled", budgetTokens: 8192 } }, + }); + expect(options.maxOutputTokens).toBe(12288); + }); + + it("fails closed when reasoning support is unknown on strict flavors", async () => { + const options = await invokeWith("openai", "gpt-test", undefined, { + reasoningEffort: "high", + }); + expect(options.providerOptions).toBeUndefined(); + expect(options.maxOutputTokens).toBeUndefined(); + }); + + it("maps gateway (rowboat) effort through the OpenRouter shape without known support", async () => { + const options = await invokeWith("rowboat", "google/gemini-3.5-flash", undefined, { + reasoningEffort: "high", + }); + expect(options.providerOptions).toEqual({ + openrouter: { reasoning: { effort: "high" } }, + }); + }); + + it("lets explicit persisted providerOptions win over the mapping", async () => { + const options = await invokeWith("openai", "o4-mini", true, { + reasoningEffort: "high", + providerOptions: { openai: { reasoningEffort: "low" } }, + }); + expect(options.providerOptions).toEqual({ + openai: { reasoningEffort: "low" }, + }); + }); + + it("raises an explicit maxOutputTokens to the thinking floor but never lowers it", async () => { + const raised = await invokeWith("anthropic", "claude-x", true, { + reasoningEffort: "high", + maxOutputTokens: 4096, + }); + expect(raised.maxOutputTokens).toBe(20480); + + const kept = await invokeWith("anthropic", "claude-x", true, { + reasoningEffort: "high", + maxOutputTokens: 32000, + }); + expect(kept.maxOutputTokens).toBe(32000); + }); + + it("sends nothing for unknown effort values or unmapped flavors", async () => { + const bogus = await invokeWith("anthropic", "claude-x", true, { + reasoningEffort: "xhigh", + }); + expect(bogus.providerOptions).toBeUndefined(); + + const local = await invokeWith("openai-compatible", "my-vllm", true, { + reasoningEffort: "high", + }); + expect(local.providerOptions).toBeUndefined(); + }); + }); + it("throws on provider error parts (a model failure, not a completion)", async () => { const registry = makeRegistry( [{ type: "error", error: new Error("rate limited") }], diff --git a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts index 40665a44..11897908 100644 --- a/apps/x/packages/core/src/turns/bridges/real-model-registry.ts +++ b/apps/x/packages/core/src/turns/bridges/real-model-registry.ts @@ -14,8 +14,10 @@ import type { JsonValue, ModelDescriptor, TurnUsage } from "@x/shared/dist/turns import { convertFromMessages } from "../../agents/runtime.js"; import { resolveProviderConfig } from "../../models/defaults.js"; import { createProvider } from "../../models/models.js"; +import { isReasoningModel } from "../../models/models-dev.js"; import { applyPromptCaching } from "../../models/prompt-caching.js"; import { applyLocalModelSettings } from "../../models/local.js"; +import { mapReasoningEffort, parseReasoningEffort } from "../../models/reasoning.js"; import type { IModelRegistry, LlmStreamEvent, @@ -46,6 +48,9 @@ export interface RealModelRegistryDeps { resolveProvider?: (name: string) => Promise>; createProviderImpl?: typeof createProvider; invoke?: StreamTextInvoker; + // Capability probe for "does this model reason?" (models.dev cache by + // default). undefined = unknown; the effort mapping fails closed on it. + reasoningSupport?: (flavor: string, modelId: string) => Promise; } // Bridges models.json provider configs to live AI SDK models and normalizes @@ -57,11 +62,16 @@ export class RealModelRegistry implements IModelRegistry { ) => Promise>; private readonly createProviderImpl: typeof createProvider; private readonly invoke: StreamTextInvoker; + private readonly reasoningSupport: ( + flavor: string, + modelId: string, + ) => Promise; constructor(deps: RealModelRegistryDeps = {}) { this.resolveProvider = deps.resolveProvider ?? resolveProviderConfig; this.createProviderImpl = deps.createProviderImpl ?? createProvider; this.invoke = deps.invoke ?? defaultInvoker; + this.reasoningSupport = deps.reasoningSupport ?? isReasoningModel; } async resolve( @@ -74,6 +84,12 @@ export class RealModelRegistry implements IModelRegistry { provider.languageModel(descriptor.model), providerConfig, ); + // Cache-only capability lookup (never blocks a turn on the network); + // unknown support makes the effort mapping fail closed. + const supportsReasoning = await this.reasoningSupport( + providerConfig.flavor, + descriptor.model, + ).catch(() => undefined); return { descriptor, // The structural -> wire conversion the app uses today: weaves @@ -83,7 +99,13 @@ export class RealModelRegistry implements IModelRegistry { encodeMessages: (messages) => convertFromMessages(messages) as unknown as JsonValue[], stream: (request) => - this.run(model, request, providerConfig.flavor, descriptor.model), + this.run( + model, + request, + providerConfig.flavor, + descriptor.model, + supportsReasoning, + ), }; } @@ -92,6 +114,7 @@ export class RealModelRegistry implements IModelRegistry { request: ModelStreamRequest, flavor: string, modelId: string, + supportsReasoning?: boolean, ): AsyncGenerator { const tools: ToolSet = {}; for (const descriptor of request.tools) { @@ -111,21 +134,72 @@ export class RealModelRegistry implements IModelRegistry { // Persisted per-call parameters (turn-runtime-design.md §8.3): only // the whitelisted generation knobs are forwarded to the provider. const params = request.parameters ?? {}; + + // Canonical reasoningEffort maps to provider-specific options here, + // transport-only (like prompt caching) — persisted events carry only + // the canonical value. Explicit persisted providerOptions win over + // the mapping; an explicit maxOutputTokens is only ever raised to + // the thinking-budget floor, never lowered. + const effort = parseReasoningEffort(params.reasoningEffort); + const reasoning = effort === undefined + ? undefined + : mapReasoningEffort(flavor, modelId, effort, supportsReasoning); + + const callerProviderOptions = + params.providerOptions && typeof params.providerOptions === "object" && !Array.isArray(params.providerOptions) + ? params.providerOptions as Record> + : undefined; + const providerOptions = reasoning === undefined + ? callerProviderOptions + : mergeProviderOptions(reasoning.providerOptions, callerProviderOptions); + + const callerMaxOutputTokens = + typeof params.maxOutputTokens === "number" ? params.maxOutputTokens : undefined; + const maxOutputTokens = + reasoning?.minOutputTokens === undefined + ? callerMaxOutputTokens + : Math.max(callerMaxOutputTokens ?? 0, reasoning.minOutputTokens); + const generationParams = { ...(typeof params.temperature === "number" ? { temperature: params.temperature } : {}), ...(typeof params.topP === "number" ? { topP: params.topP } : {}), - ...(typeof params.maxOutputTokens === "number" ? { maxOutputTokens: params.maxOutputTokens } : {}), - ...(params.providerOptions && typeof params.providerOptions === "object" && !Array.isArray(params.providerOptions) - ? { providerOptions: params.providerOptions as Record> } - : {}), + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), + ...(providerOptions === undefined ? {} : { providerOptions }), }; const parts: Array> = []; + // Per-block accumulation: providers attach round-trip metadata to + // individual content blocks (Anthropic thinking signatures arrive on + // reasoning-delta, redacted thinking on reasoning-start; Gemini + // thoughtSignatures on text-end / reasoning-end / tool-call; OpenAI + // encrypted reasoning on reasoning events). Each -start opens a new + // part so distinct blocks keep distinct signatures, and metadata from + // every event of a block is merged onto that part's providerOptions — + // which is exactly what the AI SDK providers read back when the + // message is echoed in later steps. + let currentText: TextContentPart | null = null; + let currentReasoning: ReasoningContentPart | null = null; let textBuffer = ""; let reasoningBuffer = ""; let finishReason = "unknown"; let usage: z.infer = {}; let providerMetadata: JsonValue | undefined; + // finish-step metadata also rides the assistant message as + // message-level providerOptions: providers put whole-response + // round-trip state there (OpenRouter's accumulated reasoning_details + // with thinking signatures — message-level wins over per-part + // fragments on read-back), and convertFromMessages echoes it. + let messageProviderOptions: PartProviderOptions | undefined; + + const tagPart = ( + part: TextContentPart | ReasoningContentPart, + metadata: unknown, + ) => { + const merged = mergeProviderOptions(part.providerOptions, metadata); + if (merged !== undefined) { + part.providerOptions = merged; + } + }; // Anthropic-family models get cache_control breakpoints; everything // else passes through byte-identical (transport-only, not persisted). @@ -157,56 +231,74 @@ export class RealModelRegistry implements IModelRegistry { error?: unknown; }; switch (event.type) { - case "text-start": + case "text-start": { textBuffer = ""; + currentText = { type: "text", text: "" }; + tagPart(currentText, event.providerMetadata); + parts.push(currentText); yield { type: "step_event", event: { type: "text_start" } }; break; + } case "text-delta": { const delta = event.text ?? ""; textBuffer += delta; - const last = parts[parts.length - 1]; - if (last?.type === "text") { - last.text += delta; - } else { - parts.push({ type: "text", text: delta }); + if (currentText === null) { + currentText = { type: "text", text: "" }; + parts.push(currentText); } + currentText.text += delta; + tagPart(currentText, event.providerMetadata); yield { type: "text_delta", delta }; break; } case "text-end": + if (currentText !== null) { + tagPart(currentText, event.providerMetadata); + currentText = null; + } yield { type: "step_event", event: { type: "text_end", text: textBuffer }, }; break; - case "reasoning-start": + case "reasoning-start": { reasoningBuffer = ""; + currentReasoning = { type: "reasoning", text: "" }; + tagPart(currentReasoning, event.providerMetadata); + parts.push(currentReasoning); yield { type: "step_event", event: { type: "reasoning_start" } }; break; + } case "reasoning-delta": { const delta = event.text ?? ""; reasoningBuffer += delta; - const last = parts[parts.length - 1]; - if (last?.type === "reasoning") { - last.text += delta; - } else { - parts.push({ type: "reasoning", text: delta }); + if (currentReasoning === null) { + currentReasoning = { type: "reasoning", text: "" }; + parts.push(currentReasoning); } + currentReasoning.text += delta; + tagPart(currentReasoning, event.providerMetadata); yield { type: "reasoning_delta", delta }; break; } case "reasoning-end": + if (currentReasoning !== null) { + tagPart(currentReasoning, event.providerMetadata); + currentReasoning = null; + } yield { type: "step_event", event: { type: "reasoning_end", text: reasoningBuffer }, }; break; case "tool-call": { + const partOptions = mergeProviderOptions(undefined, event.providerMetadata); const toolCall = { type: "tool-call" as const, toolCallId: String(event.toolCallId), toolName: String(event.toolName), arguments: event.input, + ...(partOptions === undefined ? {} : { providerOptions: partOptions }), }; parts.push(toolCall); yield { type: "step_event", event: { type: "tool_call", toolCall } }; @@ -216,6 +308,10 @@ export class RealModelRegistry implements IModelRegistry { finishReason = event.finishReason ?? "unknown"; usage = mapUsage(event.usage); providerMetadata = toJsonValue(event.providerMetadata); + messageProviderOptions = mergeProviderOptions( + messageProviderOptions, + event.providerMetadata, + ); yield { type: "step_event", event: { @@ -238,11 +334,24 @@ export class RealModelRegistry implements IModelRegistry { } } + // Blocks that ended up with no text and no round-trip metadata carry + // no information; dropping them matches the pre-block-tracking + // behavior (parts used to be created lazily on first delta). + const content = parts.filter( + (part) => + part.type === "tool-call" + || part.text !== "" + || part.providerOptions !== undefined, + ); + yield { type: "completed", message: { role: "assistant", - content: parts.length > 0 ? parts : "", + content: content.length > 0 ? content : "", + ...(messageProviderOptions === undefined + ? {} + : { providerOptions: messageProviderOptions }), }, finishReason, usage, @@ -251,6 +360,35 @@ export class RealModelRegistry implements IModelRegistry { } } +type TextContentPart = Extract, { type: "text" }>; +type ReasoningContentPart = Extract, { type: "reasoning" }>; +type PartProviderOptions = NonNullable; + +// Round-trip metadata is relayed opaquely: whatever JSON-safe, per-provider +// record a stream event carries is merged onto the part (later events win +// per field within a provider's namespace). The bridge never interprets the +// contents — Anthropic signatures, Gemini thoughtSignatures, and OpenAI +// encrypted reasoning all ride the same channel, and each provider reads +// back only its own keys. +function mergeProviderOptions( + base: PartProviderOptions | undefined, + incoming: unknown, +): PartProviderOptions | undefined { + const sanitized = toJsonValue(incoming); + if (sanitized === undefined || sanitized === null || typeof sanitized !== "object" || Array.isArray(sanitized)) { + return base; + } + let merged: PartProviderOptions | undefined; + for (const [provider, fields] of Object.entries(sanitized)) { + if (fields === null || typeof fields !== "object" || Array.isArray(fields)) { + continue; + } + merged = merged ?? { ...(base ?? {}) }; + merged[provider] = { ...(merged[provider] ?? {}), ...fields }; + } + return merged ?? base; +} + function mapUsage( usage: Record | undefined, ): z.infer { diff --git a/apps/x/packages/core/src/turns/runtime.test.ts b/apps/x/packages/core/src/turns/runtime.test.ts index 5b1c8d41..9bc8b072 100644 --- a/apps/x/packages/core/src/turns/runtime.test.ts +++ b/apps/x/packages/core/src/turns/runtime.test.ts @@ -510,6 +510,61 @@ describe("plain model response (26.1)", () => { }); }); +// --------------------------------------------------------------------------- +// Per-turn reasoning effort (turn_created.config → per-call parameters) +// --------------------------------------------------------------------------- + +describe("per-turn reasoning effort", () => { + it("stamps the turn's reasoningEffort into every model call's persisted parameters", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [ + respond(completedResp(assistantCalls(toolCallPart("A", "echo", { i: 1 })))), + respond(completedResp(assistantText("done"))), + ], + }); + const turnId = await newTurn(runtime, { + config: { humanAvailable: true, reasoningEffort: "high" }, + }); + const { outcome } = await advanceAndSettle(runtime, turnId); + expect(outcome?.status).toBe("completed"); + + const log = await persisted(repo, turnId); + const requests = log.filter((e) => e.type === "model_call_requested"); + expect(requests).toHaveLength(2); + for (const event of requests) { + expect( + event.type === "model_call_requested" + ? event.request.parameters + : undefined, + ).toEqual({ reasoningEffort: "high" }); + } + // The live stream received the persisted parameters verbatim. + expect(models.requests[0].parameters).toEqual({ reasoningEffort: "high" }); + expect(models.requests[1].parameters).toEqual({ reasoningEffort: "high" }); + // And the effort rides the durable turn_created config. + const created = log[0]; + expect( + created.type === "turn_created" ? created.config.reasoningEffort : undefined, + ).toBe("high"); + }); + + it("leaves parameters empty when no effort is set (auto)", async () => { + const { runtime, repo, models } = makeRuntime({ + models: [respond(completedResp(assistantText("done")))], + }); + const turnId = await newTurn(runtime); + await advanceAndSettle(runtime, turnId); + const log = await persisted(repo, turnId); + const request = log.find((e) => e.type === "model_call_requested"); + expect( + request?.type === "model_call_requested" + ? request.request.parameters + : undefined, + ).toEqual({}); + expect(models.requests[0].parameters).toEqual({}); + }); +}); + // --------------------------------------------------------------------------- // §26.2 Mixed sync and async tools // --------------------------------------------------------------------------- diff --git a/apps/x/packages/core/src/turns/runtime.ts b/apps/x/packages/core/src/turns/runtime.ts index 521a8f19..d027e385 100644 --- a/apps/x/packages/core/src/turns/runtime.ts +++ b/apps/x/packages/core/src/turns/runtime.ts @@ -164,6 +164,9 @@ export class TurnRuntime implements ITurnRuntime { autoPermission: input.config.autoPermission ?? false, humanAvailable: input.config.humanAvailable, maxModelCalls: input.config.maxModelCalls ?? DEFAULT_MAX_MODEL_CALLS, + ...(input.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: input.config.reasoningEffort }), }, }); await this.turnRepo.create(event); @@ -1110,10 +1113,16 @@ class TurnAdvance { ] : []; // re-issue after an interrupted call adds nothing new } + // The turn's reasoning effort is stamped on every call's persisted + // parameters (turn-runtime-design.md §8.3): each step durably records + // what it ran with, and the model bridge maps the canonical value to + // provider-specific options at invoke time. + const reasoningEffort = this.definition.config.reasoningEffort; const request: z.infer = { ...(isRef && index === 0 ? { contextRef: context } : {}), messages: refs, - parameters: {}, + parameters: + reasoningEffort === undefined ? {} : { reasoningEffort }, }; await this.append({ type: "model_call_requested", @@ -1290,7 +1299,22 @@ function parseToolAdditions( } function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); + // Provider API errors carry the actual upstream failure in responseBody + // (AI SDK APICallError; RetryError wraps the last one as lastError). + // "Failed after 3 attempts" alone is undebuggable — persist the payload, + // bounded so request events stay reference-sized. + const source = (error as { lastError?: unknown }).lastError ?? error; + const statusCode = (source as { statusCode?: unknown }).statusCode; + const responseBody = (source as { responseBody?: unknown }).responseBody; + const details: string[] = []; + if (typeof statusCode === "number") { + details.push(`status ${statusCode}`); + } + if (typeof responseBody === "string" && responseBody.trim().length > 0) { + details.push(responseBody.slice(0, 2000)); + } + return details.length > 0 ? `${message} [${details.join(" — ")}]` : message; } function outcomeFromTerminal(state: TurnState): TurnOutcome { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c00220bc..17872e88 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js'; import { ListToolsResponse } from './mcp.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; -import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef } from './models.js'; +import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef, ReasoningEffort } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; @@ -488,6 +488,7 @@ const ipcSchemas = { agent: RequestedAgent, autoPermission: z.boolean().optional(), maxModelCalls: z.number().int().positive().optional(), + reasoningEffort: ReasoningEffort.optional(), }), }), res: z.object({ turnId: z.string() }), @@ -585,6 +586,9 @@ const ipcSchemas = { id: z.string(), name: z.string().optional(), release_date: z.string().optional(), + // models.dev "supports reasoning/extended thinking" flag; absent = + // unknown. Gates the composer's reasoning-effort control. + reasoning: z.boolean().optional(), })), })), lastUpdated: z.string().optional(), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 7d51a089..5ed03104 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -1,5 +1,13 @@ import { z } from "zod"; +// Canonical reasoning-effort ladder, used everywhere effort appears: the +// per-provider default in models.json, the per-turn override on turn +// creation, and the persisted per-call parameters. Absence means "auto" — +// send nothing and let the provider default apply. Provider-specific +// syntax (OpenAI reasoningEffort, Anthropic thinking budgets, Gemini +// thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time. +export const ReasoningEffort = z.enum(["low", "medium", "high"]); + export const LlmProvider = z.object({ flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]), apiKey: z.string().optional(), @@ -9,11 +17,12 @@ export const LlmProvider = z.object({ // to a ~4k window that silently truncates Rowboat's prompts; when unset, // local providers get a larger default (see core/models/local.ts). contextLength: z.number().int().positive().optional(), - // Reasoning effort for local thinking models (Ollama `think` parameter). - // gpt-oss supports the levels directly; other thinking models map - // low → thinking off, high → thinking on. Defaults to "low" for Ollama — - // background agents and chat both want snappy responses on local hardware. - reasoningEffort: z.enum(["low", "medium", "high"]).optional(), + // Default reasoning effort for this provider. For Ollama this drives the + // `think` parameter (gpt-oss takes the levels directly; other thinking + // models map low → off, high → on; defaults to "low" — background agents + // and chat both want snappy responses on local hardware). For cloud + // providers it seeds the per-turn effort when the user hasn't chosen one. + reasoningEffort: ReasoningEffort.optional(), }); // A provider-qualified model reference. `provider` is a provider name as @@ -47,7 +56,7 @@ export const LlmModelConfig = z.object({ baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), contextLength: z.number().int().positive().optional(), - reasoningEffort: z.enum(["low", "medium", "high"]).optional(), + reasoningEffort: ReasoningEffort.optional(), model: z.string().optional(), models: z.array(z.string()).optional(), knowledgeGraphModel: z.string().optional(), diff --git a/apps/x/packages/shared/src/turns.ts b/apps/x/packages/shared/src/turns.ts index 192e77ff..9a6c14cc 100644 --- a/apps/x/packages/shared/src/turns.ts +++ b/apps/x/packages/shared/src/turns.ts @@ -5,6 +5,7 @@ import { ToolMessage, UserMessage, } from "./message.js"; +import { ReasoningEffort } from "./models.js"; // Durable turn contract for the turn runtime (see // packages/core/docs/turn-runtime-design.md). This module is the @@ -176,6 +177,10 @@ export const TurnCreated = z.object({ autoPermission: z.boolean(), humanAvailable: z.boolean(), maxModelCalls: z.number().int().positive(), + // Canonical per-turn reasoning effort; absent = auto (provider + // default). Stamped into every model call's parameters and mapped + // to provider-specific options at invoke time. + reasoningEffort: ReasoningEffort.optional(), }), });