mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #717 from rowboatlabs/reasoning-effort
Per-turn reasoning effort control
This commit is contained in:
commit
6375704d66
23 changed files with 1197 additions and 57 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
68
apps/x/packages/core/src/models/models-dev.test.ts
Normal file
68
apps/x/packages/core/src/models/models-dev.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
76
apps/x/packages/core/src/models/reasoning.test.ts
Normal file
76
apps/x/packages/core/src/models/reasoning.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
101
apps/x/packages/core/src/models/reasoning.ts
Normal file
101
apps/x/packages/core/src/models/reasoning.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ export interface SendMessageConfig {
|
|||
agent: z.infer<typeof RequestedAgent>;
|
||||
autoPermission?: boolean;
|
||||
maxModelCalls?: number;
|
||||
reasoningEffort?: "low" | "medium" | "high";
|
||||
}
|
||||
|
||||
export interface ISessions {
|
||||
|
|
|
|||
|
|
@ -189,6 +189,9 @@ export class SessionsImpl implements ISessions {
|
|||
...(config.maxModelCalls === undefined
|
||||
? {}
|
||||
: { maxModelCalls: config.maxModelCalls }),
|
||||
...(config.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: config.reasoningEffort }),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") }],
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue