Merge pull request #717 from rowboatlabs/reasoning-effort

Per-turn reasoning effort control
This commit is contained in:
Ramnique Singh 2026-07-10 11:38:25 +05:30 committed by GitHub
commit 6375704d66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1197 additions and 57 deletions

View file

@ -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.

View file

@ -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<string, string>())
const selectedModelByTabRef = useRef(new Map<string, { provider: string; model: string }>())
// 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<string, 'low' | 'medium' | 'high'>())
// Work directory is per-chat. Keyed by tab id; null/absent means none set.
const [workDirByTab, setWorkDirByTab] = useState<Record<string, string | null>>({})
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<ReturnType<typeof buildMiddlePaneContext>>) => ({
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 (
<div key={item.id} className="-mt-6 flex justify-start px-1" data-message-id={item.id}>
<div key={item.id} className="-mt-6 flex items-center justify-start gap-1 px-1" data-message-id={item.id}>
<TokenUsageMenu
usage={item.usage}
scope="turn"
modelCallCount={item.modelCallCount}
align="start"
/>
{item.reasoningEffort && (
<span className="text-xs text-muted-foreground/70">
{REASONING_EFFORT_LABELS[item.reasoningEffort]}
</span>
)}
</div>
)
}
@ -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}

View file

@ -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<ConfiguredModel | null>(null)
const loadModelConfigEpoch = useRef(0)
const [lockedModel, setLockedModel] = useState<SelectedModel | null>(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<Record<string, boolean>>({})
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<string, string[]> = {}
const reasoningFlags: Record<string, boolean> = {}
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({
</DropdownMenu>
)}
<div className="flex-1" />
{reasoningAvailable && (
<DropdownMenu>
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Brain className="h-3 w-3 shrink-0" />
{reasoningEffort !== '' && (
<span>{REASONING_EFFORT_OPTIONS.find((o) => o.value === reasoningEffort)?.label}</span>
)}
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Reasoning effort applies to your next message</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={reasoningEffort} onValueChange={handleReasoningEffortChange}>
{REASONING_EFFORT_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value || 'auto'} value={option.value}>
<span>{option.label}</span>
<span className="ml-2 text-xs text-muted-foreground">{option.hint}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{lockedModel ? (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
@ -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}

View file

@ -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<string, string | null>
/** Composer locks for runs bound to Code-section sessions (cwd + agent frozen). */
codeSessionLocks?: Record<string, { cwd: string; agent: 'claude' | 'codex' }>
@ -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 (
<div key={item.id} className="-mt-6 flex justify-start px-1" data-message-id={item.id}>
<div key={item.id} className="-mt-6 flex items-center justify-start gap-1 px-1" data-message-id={item.id}>
<TokenUsageMenu
usage={item.usage}
scope="turn"
modelCallCount={item.modelCallCount}
align="start"
/>
{item.reasoningEffort && (
<span className="text-xs text-muted-foreground/70">
{REASONING_EFFORT_LABELS[item.reasoningEffort]}
</span>
)}
</div>
)
}
@ -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}

View file

@ -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<ReasoningEffortLevel, string> = {
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
}

View file

@ -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(),
})
}

View file

@ -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.

View file

@ -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 }),
},
});

View file

@ -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',

View file

@ -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);
});
});

View file

@ -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<void> {
}
async function fetchModelsDev(): Promise<unknown> {
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<typeof ModelsDevResponse>; 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<void> | null = null;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
async function refreshCache(force: boolean): Promise<void> {
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<void> {
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<typeof ModelsDevResponse>; 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<string, z.infer<typeof ModelsDevModel>>)
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<string, z.infer<typeof ModelsDevModel>>)
}
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<typeof ModelsDevResponse>,
): Map<string, boolean> {
const index = new Map<string, boolean>();
const ambiguous = new Set<string>();
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<string, boolean>,
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<Map<string, boolean> | 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<boolean | undefined> {
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<T extends { id: string }>(
models: T[],
): Promise<Array<T & { reasoning?: boolean }>> {
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<Set<string>> {
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<string>();
for (const [id, model] of Object.entries(provider.models)) {

View file

@ -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();
});
});

View file

@ -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<typeof ReasoningEffort>;
export interface ReasoningRequestOptions {
providerOptions: Record<string, Record<string, JsonValue>>;
// 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;
}
}

View file

@ -16,6 +16,7 @@ export interface SendMessageConfig {
agent: z.infer<typeof RequestedAgent>;
autoPermission?: boolean;
maxModelCalls?: number;
reasoningEffort?: "low" | "medium" | "high";
}
export interface ISessions {

View file

@ -189,6 +189,9 @@ export class SessionsImpl implements ISessions {
...(config.maxModelCalls === undefined
? {}
: { maxModelCalls: config.maxModelCalls }),
...(config.reasoningEffort === undefined
? {}
: { reasoningEffort: config.reasoningEffort }),
},
});

View file

@ -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";
};
}

View file

@ -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<Record<string, unknown>> }>;
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<string, unknown>,
): Promise<InvokerOptions> {
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") }],

View file

@ -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<z.infer<typeof LlmProvider>>;
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<boolean | undefined>;
}
// Bridges models.json provider configs to live AI SDK models and normalizes
@ -57,11 +62,16 @@ export class RealModelRegistry implements IModelRegistry {
) => Promise<z.infer<typeof LlmProvider>>;
private readonly createProviderImpl: typeof createProvider;
private readonly invoke: StreamTextInvoker;
private readonly reasoningSupport: (
flavor: string,
modelId: string,
) => Promise<boolean | undefined>;
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<LlmStreamEvent, void, void> {
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<string, Record<string, JsonValue>>
: 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<string, Record<string, JsonValue>> }
: {}),
...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
...(providerOptions === undefined ? {} : { providerOptions }),
};
const parts: Array<z.infer<typeof AssistantContentPart>> = [];
// 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<typeof TurnUsage> = {};
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<z.infer<typeof AssistantContentPart>, { type: "text" }>;
type ReasoningContentPart = Extract<z.infer<typeof AssistantContentPart>, { type: "reasoning" }>;
type PartProviderOptions = NonNullable<TextContentPart["providerOptions"]>;
// 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<string, number | undefined> | undefined,
): z.infer<typeof TurnUsage> {

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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<typeof ModelRequest> = {
...(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 {

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),
}),
});