diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 602c0956..de75fb4a 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -817,6 +817,7 @@ function App() { const chatTabIdCounterRef = useRef(0) const newChatTabId = () => `chat-tab-${++chatTabIdCounterRef.current}` const chatDraftsRef = useRef(new Map()) + const selectedModelByTabRef = useRef(new Map()) const chatScrollTopByTabRef = useRef(new Map()) const [toolOpenByTab, setToolOpenByTab] = useState>>({}) const [chatViewportAnchorByTab, setChatViewportAnchorByTab] = useState>({}) @@ -2165,8 +2166,10 @@ function App() { let isNewRun = false let newRunCreatedAt: string | null = null if (!currentRunId) { + const selected = selectedModelByTabRef.current.get(submitTabId) const run = await window.ipc.invoke('runs:create', { agentId, + ...(selected ? { model: selected.model, provider: selected.provider } : {}), }) currentRunId = run.id newRunCreatedAt = run.createdAt @@ -2471,6 +2474,7 @@ function App() { return next }) chatDraftsRef.current.delete(tabId) + selectedModelByTabRef.current.delete(tabId) chatScrollTopByTabRef.current.delete(tabId) setToolOpenByTab((prev) => { if (!(tabId in prev)) return prev @@ -4644,6 +4648,13 @@ function App() { runId={tabState.runId} initialDraft={chatDraftsRef.current.get(tab.id)} onDraftChange={(text) => setChatDraftForTab(tab.id, text)} + onSelectedModelChange={(m) => { + if (m) { + selectedModelByTabRef.current.set(tab.id, m) + } else { + selectedModelByTabRef.current.delete(tab.id) + } + }} isRecording={isActive && isRecording} recordingText={isActive ? voice.interimText : undefined} recordingState={isActive ? (voice.state === 'connecting' ? 'connecting' : 'listening') : undefined} @@ -4697,6 +4708,13 @@ function App() { onPresetMessageConsumed={() => setPresetMessage(undefined)} getInitialDraft={(tabId) => chatDraftsRef.current.get(tabId)} onDraftChangeForTab={setChatDraftForTab} + onSelectedModelChangeForTab={(tabId, m) => { + if (m) { + selectedModelByTabRef.current.set(tabId, m) + } else { + selectedModelByTabRef.current.delete(tabId) + } + }} pendingAskHumanRequests={pendingAskHumanRequests} allPermissionRequests={allPermissionRequests} permissionResponses={permissionResponses} diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 37d8d053..0d2eb13d 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -69,13 +69,16 @@ const providerDisplayNames: Record = { rowboat: 'Rowboat', } +type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" + interface ConfiguredModel { - flavor: "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" + provider: ProviderName + model: string +} + +export interface SelectedModel { + provider: string model: string - apiKey?: string - baseURL?: string - headers?: Record - knowledgeGraphModel?: string } function getAttachmentIcon(kind: AttachmentIconKind) { @@ -120,6 +123,8 @@ interface ChatInputInnerProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + /** Fired when the user picks a different model in the dropdown (only when no run exists yet). */ + onSelectedModelChange?: (model: SelectedModel | null) => void } function ChatInputInner({ @@ -145,6 +150,7 @@ function ChatInputInner({ ttsMode, onToggleTts, onTtsModeChange, + onSelectedModelChange, }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value @@ -155,10 +161,27 @@ function ChatInputInner({ const [configuredModels, setConfiguredModels] = useState([]) const [activeModelKey, setActiveModelKey] = useState('') + const [lockedModel, setLockedModel] = useState(null) const [searchEnabled, setSearchEnabled] = useState(false) const [searchAvailable, setSearchAvailable] = useState(false) const [isRowboatConnected, setIsRowboatConnected] = useState(false) + // When a run exists, freeze the dropdown to the run's resolved model+provider. + useEffect(() => { + if (!runId) { + setLockedModel(null) + return + } + let cancelled = false + window.ipc.invoke('runs:fetch', { runId }).then((run) => { + if (cancelled) return + if (run.provider && run.model) { + setLockedModel({ provider: run.provider, model: run.model }) + } + }).catch(() => { /* legacy run or fetch failure — leave unlocked */ }) + return () => { cancelled = true } + }, [runId]) + // Check Rowboat sign-in state useEffect(() => { window.ipc.invoke('oauth:getState', null).then((result) => { @@ -176,42 +199,20 @@ function ChatInputInner({ return cleanup }, []) - // Load model config (gateway when signed in, local config when BYOK) + // Load the list of models the user can choose from. + // Signed-in: gateway model list. Signed-out: providers configured in models.json. const loadModelConfig = useCallback(async () => { try { if (isRowboatConnected) { - // Fetch gateway models const listResult = await window.ipc.invoke('models:list', null) const rowboatProvider = listResult.providers?.find( (p: { id: string }) => p.id === 'rowboat' ) const models: ConfiguredModel[] = (rowboatProvider?.models || []).map( - (m: { id: string }) => ({ flavor: 'rowboat', model: m.id }) + (m: { id: string }) => ({ provider: 'rowboat', model: m.id }) ) - - // Read current default from config - let defaultModel = '' - try { - const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) - const parsed = JSON.parse(result.data) - defaultModel = parsed?.model || '' - } catch { /* no config yet */ } - - if (defaultModel) { - models.sort((a, b) => { - if (a.model === defaultModel) return -1 - if (b.model === defaultModel) return 1 - return 0 - }) - } - setConfiguredModels(models) - const activeKey = defaultModel - ? `rowboat/${defaultModel}` - : models[0] ? `rowboat/${models[0].model}` : '' - if (activeKey) setActiveModelKey(activeKey) } else { - // BYOK: read from local models.json const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) const models: ConfiguredModel[] = [] @@ -223,32 +224,12 @@ function ChatInputInner({ const allModels = modelList.length > 0 ? modelList : singleModel ? [singleModel] : [] for (const model of allModels) { if (model) { - models.push({ - flavor: flavor as ConfiguredModel['flavor'], - model, - apiKey: (e.apiKey as string) || undefined, - baseURL: (e.baseURL as string) || undefined, - headers: (e.headers as Record) || undefined, - knowledgeGraphModel: (e.knowledgeGraphModel as string) || undefined, - }) + models.push({ provider: flavor as ProviderName, model }) } } } } - const defaultKey = parsed?.provider?.flavor && parsed?.model - ? `${parsed.provider.flavor}/${parsed.model}` - : '' - models.sort((a, b) => { - const aKey = `${a.flavor}/${a.model}` - const bKey = `${b.flavor}/${b.model}` - if (aKey === defaultKey) return -1 - if (bKey === defaultKey) return 1 - return 0 - }) setConfiguredModels(models) - if (defaultKey) { - setActiveModelKey(defaultKey) - } } } catch { // No config yet @@ -284,40 +265,15 @@ function ChatInputInner({ checkSearch() }, [isActive, isRowboatConnected]) - const handleModelChange = useCallback(async (key: string) => { - const entry = configuredModels.find((m) => `${m.flavor}/${m.model}` === key) + // Selecting a model affects only the *next* run created from this tab. + // Once a run exists, model is frozen on the run and the dropdown is read-only. + const handleModelChange = useCallback((key: string) => { + if (lockedModel) return + const entry = configuredModels.find((m) => `${m.provider}/${m.model}` === key) if (!entry) return setActiveModelKey(key) - - try { - if (entry.flavor === 'rowboat') { - // Gateway model — save with valid Zod flavor, no credentials - await window.ipc.invoke('models:saveConfig', { - provider: { flavor: 'openrouter' as const }, - model: entry.model, - knowledgeGraphModel: entry.knowledgeGraphModel, - }) - } else { - // BYOK — preserve full provider config - const providerModels = configuredModels - .filter((m) => m.flavor === entry.flavor) - .map((m) => m.model) - await window.ipc.invoke('models:saveConfig', { - provider: { - flavor: entry.flavor, - apiKey: entry.apiKey, - baseURL: entry.baseURL, - headers: entry.headers, - }, - model: entry.model, - models: providerModels, - knowledgeGraphModel: entry.knowledgeGraphModel, - }) - } - } catch { - toast.error('Failed to switch model') - } - }, [configuredModels]) + onSelectedModelChange?.({ provider: entry.provider, model: entry.model }) + }, [configuredModels, lockedModel, onSelectedModelChange]) // Restore the tab draft when this input mounts. useEffect(() => { @@ -555,7 +511,14 @@ function ChatInputInner({ ) )}
- {configuredModels.length > 0 && ( + {lockedModel ? ( + + {lockedModel.model} + + ) : configuredModels.length > 0 ? ( @@ -571,18 +534,18 @@ function ChatInputInner({ {configuredModels.map((m) => { - const key = `${m.flavor}/${m.model}` + const key = `${m.provider}/${m.model}` return ( {m.model} - {providerDisplayNames[m.flavor] || m.flavor} + {providerDisplayNames[m.provider] || m.provider} ) })} - )} + ) : null} {onToggleTts && ttsAvailable && (
@@ -729,6 +692,7 @@ export interface ChatInputWithMentionsProps { ttsMode?: 'summary' | 'full' onToggleTts?: () => void onTtsModeChange?: (mode: 'summary' | 'full') => void + onSelectedModelChange?: (model: SelectedModel | null) => void } export function ChatInputWithMentions({ @@ -757,6 +721,7 @@ export function ChatInputWithMentions({ ttsMode, onToggleTts, onTtsModeChange, + onSelectedModelChange, }: ChatInputWithMentionsProps) { return ( @@ -783,6 +748,7 @@ export function ChatInputWithMentions({ ttsMode={ttsMode} onToggleTts={onToggleTts} onTtsModeChange={onTtsModeChange} + onSelectedModelChange={onSelectedModelChange} /> ) diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index e51d7c8f..852993a2 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -26,7 +26,7 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type StagedAttachment, type SelectedModel } from '@/components/chat-input-with-mentions' import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { @@ -158,6 +158,7 @@ interface ChatSidebarProps { onPresetMessageConsumed?: () => void getInitialDraft?: (tabId: string) => string | undefined onDraftChangeForTab?: (tabId: string, text: string) => void + onSelectedModelChangeForTab?: (tabId: string, model: SelectedModel | null) => void pendingAskHumanRequests?: ChatTabViewState['pendingAskHumanRequests'] allPermissionRequests?: ChatTabViewState['allPermissionRequests'] permissionResponses?: ChatTabViewState['permissionResponses'] @@ -211,6 +212,7 @@ export function ChatSidebar({ onPresetMessageConsumed, getInitialDraft, onDraftChangeForTab, + onSelectedModelChangeForTab, pendingAskHumanRequests = new Map(), allPermissionRequests = new Map(), permissionResponses = new Map(), @@ -662,6 +664,7 @@ export function ChatSidebar({ runId={tabState.runId} initialDraft={getInitialDraft?.(tab.id)} onDraftChange={onDraftChangeForTab ? (text) => onDraftChangeForTab(tab.id, text) : undefined} + onSelectedModelChange={onSelectedModelChangeForTab ? (m) => onSelectedModelChangeForTab(tab.id, m) : undefined} isRecording={isActive && isRecording} recordingText={isActive ? recordingText : undefined} recordingState={isActive ? recordingState : undefined} diff --git a/apps/x/packages/core/src/agent-schedule/runner.ts b/apps/x/packages/core/src/agent-schedule/runner.ts index 4eab6081..5fca6878 100644 --- a/apps/x/packages/core/src/agent-schedule/runner.ts +++ b/apps/x/packages/core/src/agent-schedule/runner.ts @@ -8,6 +8,7 @@ import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.j import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js"; import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js"; import { MessageEvent } from "@x/shared/dist/runs.js"; +import { createRun } from "../runs/runs.js"; import z from "zod"; const DEFAULT_STARTING_MESSAGE = "go"; @@ -162,8 +163,8 @@ async function runAgent( }); try { - // Create a new run - const run = await runsRepo.create({ agentId: agentName }); + // Create a new run via core (resolves agent + default model+provider). + const run = await createRun({ agentId: agentName }); console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`); // Add the starting message as a user message diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index ae69d60c..6c84ac8b 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -16,8 +16,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu import container from "../di/container.js"; import { IModelConfigRepo } from "../models/repo.js"; import { createProvider } from "../models/models.js"; -import { isSignedIn } from "../account/account.js"; -import { getGatewayProvider } from "../models/gateway.js"; +import { resolveProviderConfig } from "../models/defaults.js"; import { IAgentsRepo } from "./repo.js"; import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js"; import { IBus } from "../application/lib/bus.js"; @@ -649,6 +648,8 @@ export class AgentState { runId: string | null = null; agent: z.infer | null = null; agentName: string | null = null; + runModel: string | null = null; + runProvider: string | null = null; messages: z.infer = []; lastAssistantMsg: z.infer | null = null; subflowStates: Record = {}; @@ -762,13 +763,18 @@ export class AgentState { case "start": this.runId = event.runId; this.agentName = event.agentName; + this.runModel = event.model; + this.runProvider = event.provider; break; case "spawn-subflow": // Seed the subflow state with its agent so downstream loadAgent works. + // Subflows inherit the parent run's model+provider — there's one pair per run. if (!this.subflowStates[event.toolCallId]) { this.subflowStates[event.toolCallId] = new AgentState(); } this.subflowStates[event.toolCallId].agentName = event.agentName; + this.subflowStates[event.toolCallId].runModel = this.runModel; + this.subflowStates[event.toolCallId].runProvider = this.runProvider; break; case "message": this.messages.push(event.message); @@ -857,35 +863,23 @@ export async function* streamAgent({ yield event; } - const modelConfig = await modelConfigRepo.getConfig(); - if (!modelConfig) { - throw new Error("Model config not found"); - } - // set up agent const agent = await loadAgent(state.agentName!); // set up tools const tools = await buildTools(agent); - // set up provider + model - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(modelConfig.provider); - const knowledgeGraphAgents = ["note_creation", "email-draft", "meeting-prep", "labeling_agent", "note_tagging_agent", "agent_notes_agent"]; - const isKgAgent = knowledgeGraphAgents.includes(state.agentName!); - const isInlineTaskAgent = state.agentName === "inline_task_agent"; - const defaultModel = signedIn ? "gpt-5.4" : modelConfig.model; - const defaultKgModel = signedIn ? "anthropic/claude-haiku-4.5" : defaultModel; - const defaultInlineTaskModel = signedIn ? "anthropic/claude-sonnet-4.6" : defaultModel; - const modelId = isInlineTaskAgent - ? defaultInlineTaskModel - : (isKgAgent && modelConfig.knowledgeGraphModel) - ? modelConfig.knowledgeGraphModel - : isKgAgent ? defaultKgModel : defaultModel; + // model+provider were resolved and frozen on the run at runs:create time. + // Look up the named provider's current credentials from models.json and + // instantiate the LLM client. No selection happens here. + if (!state.runModel || !state.runProvider) { + throw new Error(`Run ${runId} is missing model/provider on its start event`); + } + const modelId = state.runModel; + const providerConfig = await resolveProviderConfig(state.runProvider); + const provider = createProvider(providerConfig); const model = provider.languageModel(modelId); - logger.log(`using model: ${modelId}`); + logger.log(`using model: ${modelId} (provider: ${state.runProvider})`); let loopCounter = 0; let voiceInput = false; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index a2b68427..52083277 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -21,9 +21,8 @@ import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/d import type { ToolContext } from "./exec-tool.js"; import { generateText } from "ai"; import { createProvider } from "../../models/models.js"; -import { IModelConfigRepo } from "../../models/repo.js"; +import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js"; import { isSignedIn } from "../../account/account.js"; -import { getGatewayProvider } from "../../models/gateway.js"; import { getAccessToken } from "../../auth/tokens.js"; import { API_URL } from "../../config/env.js"; import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js"; @@ -746,13 +745,9 @@ export const BuiltinTools: z.infer = { const base64 = buffer.toString('base64'); - // Resolve model config from DI container - const modelConfigRepo = container.resolve('modelConfigRepo'); - const modelConfig = await modelConfigRepo.getConfig(); - const provider = await isSignedIn() - ? await getGatewayProvider() - : createProvider(modelConfig.provider); - const model = provider.languageModel(modelConfig.model); + const { model: modelId, provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); const userPrompt = prompt || 'Convert this file to well-structured markdown.'; diff --git a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts index 58aa22a7..d7087405 100644 --- a/apps/x/packages/core/src/knowledge/agent_notes_agent.ts +++ b/apps/x/packages/core/src/knowledge/agent_notes_agent.ts @@ -1,5 +1,6 @@ export function getRaw(): string { return `--- +model: anthropic/claude-haiku-4.5 tools: workspace-writeFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index d25ff74b..9c3e2568 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -13,7 +13,7 @@ export function getRaw(): string { const defaultEndISO = defaultEnd.toISOString(); return `--- -model: gpt-5.2 +model: anthropic/claude-sonnet-4.6 tools: ${toolEntries} --- diff --git a/apps/x/packages/core/src/knowledge/labeling_agent.ts b/apps/x/packages/core/src/knowledge/labeling_agent.ts index d28649b1..bb4a6efe 100644 --- a/apps/x/packages/core/src/knowledge/labeling_agent.ts +++ b/apps/x/packages/core/src/knowledge/labeling_agent.ts @@ -2,7 +2,7 @@ import { renderTagSystemForEmails } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index 1740bdb7..283c77ec 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -3,7 +3,7 @@ import { renderNoteEffectRules } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-writeFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts index 0dc581f1..71b10910 100644 --- a/apps/x/packages/core/src/knowledge/note_tagging_agent.ts +++ b/apps/x/packages/core/src/knowledge/note_tagging_agent.ts @@ -2,7 +2,7 @@ import { renderTagSystemForNotes } from './tag_system.js'; export function getRaw(): string { return `--- -model: gpt-5.2 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/knowledge/summarize_meeting.ts b/apps/x/packages/core/src/knowledge/summarize_meeting.ts index 30e3c5d4..a10aac28 100644 --- a/apps/x/packages/core/src/knowledge/summarize_meeting.ts +++ b/apps/x/packages/core/src/knowledge/summarize_meeting.ts @@ -1,11 +1,8 @@ import fs from 'fs'; import path from 'path'; import { generateText } from 'ai'; -import container from '../di/container.js'; -import type { IModelConfigRepo } from '../models/repo.js'; import { createProvider } from '../models/models.js'; -import { isSignedIn } from '../account/account.js'; -import { getGatewayProvider } from '../models/gateway.js'; +import { getDefaultModelAndProvider, resolveProviderConfig } from '../models/defaults.js'; import { WorkDir } from '../config/config.js'; const CALENDAR_SYNC_DIR = path.join(WorkDir, 'calendar_sync'); @@ -138,15 +135,9 @@ function loadCalendarEventContext(calendarEventJson: string): string { } export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise { - const repo = container.resolve('modelConfigRepo'); - const config = await repo.getConfig(); - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(config.provider); - const modelId = config.meetingNotesModel - || (signedIn ? "gpt-5.4" : config.model); - const model = provider.languageModel(modelId); + const { model: modelId, provider: providerName } = await getDefaultModelAndProvider(); + const providerConfig = await resolveProviderConfig(providerName); + const model = createProvider(providerConfig).languageModel(modelId); // If a specific calendar event was linked, use it directly. // Otherwise fall back to scanning events within ±3 hours. diff --git a/apps/x/packages/core/src/knowledge/track/routing.ts b/apps/x/packages/core/src/knowledge/track/routing.ts index f876106e..53e6f7b3 100644 --- a/apps/x/packages/core/src/knowledge/track/routing.ts +++ b/apps/x/packages/core/src/knowledge/track/routing.ts @@ -1,11 +1,8 @@ import { generateObject } from 'ai'; import { trackBlock, PrefixLogger } from '@x/shared'; import type { KnowledgeEvent } from '@x/shared/dist/track-block.js'; -import container from '../../di/container.js'; -import type { IModelConfigRepo } from '../../models/repo.js'; import { createProvider } from '../../models/models.js'; -import { isSignedIn } from '../../account/account.js'; -import { getGatewayProvider } from '../../models/gateway.js'; +import { getDefaultModelAndProvider, resolveProviderConfig } from '../../models/defaults.js'; const log = new PrefixLogger('TrackRouting'); @@ -37,15 +34,9 @@ Rules: - For each candidate, return BOTH trackId and filePath exactly as given. trackIds are not globally unique.`; async function resolveModel() { - const repo = container.resolve('modelConfigRepo'); - const config = await repo.getConfig(); - const signedIn = await isSignedIn(); - const provider = signedIn - ? await getGatewayProvider() - : createProvider(config.provider); - const modelId = config.knowledgeGraphModel - || (signedIn ? 'gpt-5.4' : config.model); - return provider.languageModel(modelId); + const { model, provider } = await getDefaultModelAndProvider(); + const config = await resolveProviderConfig(provider); + return createProvider(config).languageModel(model); } function buildRoutingPrompt(event: KnowledgeEvent, batch: ParsedTrack[]): string { diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts new file mode 100644 index 00000000..b9df52da --- /dev/null +++ b/apps/x/packages/core/src/models/defaults.ts @@ -0,0 +1,53 @@ +import z from "zod"; +import { LlmProvider } from "@x/shared/dist/models.js"; +import { IModelConfigRepo } from "./repo.js"; +import { isSignedIn } from "../account/account.js"; +import container from "../di/container.js"; + +const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4"; +const SIGNED_IN_DEFAULT_PROVIDER = "rowboat"; + +/** + * The single source of truth for "what model+provider should we use when + * the caller didn't specify and the agent didn't declare". Returns names only. + * This is the only place that branches on signed-in state. + */ +export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> { + if (await isSignedIn()) { + return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER }; + } + const repo = container.resolve("modelConfigRepo"); + const cfg = await repo.getConfig(); + return { model: cfg.model, provider: cfg.provider.flavor }; +} + +/** + * Resolve a provider name (as stored on a run, an agent, or returned by + * getDefaultModelAndProvider) into the full LlmProvider config that + * createProvider expects (apiKey/baseURL/headers). + * + * - "rowboat" → gateway provider (auth via OAuth bearer; no creds field). + * - other names → look up models.json's `providers[name]` map. + * - fallback: if the name matches the active default's flavor (legacy + * single-provider configs that didn't write to the providers map yet). + */ +export async function resolveProviderConfig(name: string): Promise> { + if (name === "rowboat") { + return { flavor: "rowboat" }; + } + const repo = container.resolve("modelConfigRepo"); + const cfg = await repo.getConfig(); + const entry = cfg.providers?.[name]; + if (entry) { + return LlmProvider.parse({ + flavor: name, + apiKey: entry.apiKey, + baseURL: entry.baseURL, + headers: entry.headers, + }); + } + if (cfg.provider.flavor === name) { + return cfg.provider; + } + throw new Error(`Provider '${name}' is referenced but not configured`); +} diff --git a/apps/x/packages/core/src/models/gateway.ts b/apps/x/packages/core/src/models/gateway.ts index df9b413c..6f613704 100644 --- a/apps/x/packages/core/src/models/gateway.ts +++ b/apps/x/packages/core/src/models/gateway.ts @@ -10,7 +10,7 @@ const authedFetch: typeof fetch = async (input, init) => { return fetch(input, { ...init, headers }); }; -export async function getGatewayProvider(): Promise { +export function getGatewayProvider(): ProviderV2 { return createOpenRouter({ baseURL: `${API_URL}/v1/llm`, apiKey: 'managed-by-rowboat', diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index 38b6801f..92353f0a 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -8,7 +8,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js"; import z from "zod"; -import { isSignedIn } from "../account/account.js"; import { getGatewayProvider } from "./gateway.js"; export const Provider = LlmProvider; @@ -65,6 +64,8 @@ export function createProvider(config: z.infer): ProviderV2 { baseURL, headers, }) as unknown as ProviderV2; + case "rowboat": + return getGatewayProvider(); default: throw new Error(`Unsupported provider flavor: ${config.flavor}`); } @@ -80,9 +81,7 @@ export async function testModelConnection( const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), effectiveTimeout); try { - const provider = await isSignedIn() - ? await getGatewayProvider() - : createProvider(providerConfig); + const provider = createProvider(providerConfig); const languageModel = provider.languageModel(model); await generateText({ model: languageModel, diff --git a/apps/x/packages/core/src/pre_built/email-draft.md b/apps/x/packages/core/src/pre_built/email-draft.md index f863271b..7a353d26 100644 --- a/apps/x/packages/core/src/pre_built/email-draft.md +++ b/apps/x/packages/core/src/pre_built/email-draft.md @@ -1,5 +1,5 @@ --- -model: gpt-4.1 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/pre_built/meeting-prep.md b/apps/x/packages/core/src/pre_built/meeting-prep.md index ca6bb2fc..5dc46eda 100644 --- a/apps/x/packages/core/src/pre_built/meeting-prep.md +++ b/apps/x/packages/core/src/pre_built/meeting-prep.md @@ -1,5 +1,5 @@ --- -model: gpt-4.1 +model: anthropic/claude-haiku-4.5 tools: workspace-readFile: type: builtin diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index 5d563f1f..502976e6 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -6,9 +6,28 @@ import fsp from "fs/promises"; import fs from "fs"; import readline from "readline"; import { Run, RunEvent, StartEvent, CreateRunOptions, ListRunsResponse, MessageEvent } from "@x/shared/dist/runs.js"; +import { getDefaultModelAndProvider } from "../models/defaults.js"; + +/** + * Reading-only schemas: extend the canonical `StartEvent` / `RunEvent` to + * accept legacy run files written before `model`/`provider` were required. + * + * `RunEvent.or(LegacyStartEvent)` works because zod unions try left-to-right: + * for any non-start event RunEvent matches first; for a strict start event + * RunEvent still matches; only a legacy start event falls through and parses + * as LegacyStartEvent. New event types stay maintained in one place + * (`@x/shared/dist/runs.js`) — the lenient form just adds one fallback variant. + */ +const LegacyStartEvent = StartEvent.extend({ + model: z.string().optional(), + provider: z.string().optional(), +}); +const ReadRunEvent = RunEvent.or(LegacyStartEvent); + +export type CreateRunRepoOptions = Required>; export interface IRunsRepo { - create(options: z.infer): Promise>; + create(options: CreateRunRepoOptions): Promise>; fetch(id: string): Promise>; list(cursor?: string): Promise>; appendEvents(runId: string, events: z.infer[]): Promise; @@ -69,16 +88,19 @@ export class FSRunsRepo implements IRunsRepo { /** * Read file line-by-line using streams, stopping early once we have * the start event and title (or determine there's no title). + * + * Parses the start event with `LegacyStartEvent` so runs written before + * `model`/`provider` were required still surface in the list view. */ private async readRunMetadata(filePath: string): Promise<{ - start: z.infer; + start: z.infer; title: string | undefined; } | null> { return new Promise((resolve) => { const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - let start: z.infer | null = null; + let start: z.infer | null = null; let title: string | undefined; let lineIndex = 0; @@ -88,11 +110,10 @@ export class FSRunsRepo implements IRunsRepo { try { if (lineIndex === 0) { - // First line should be the start event - start = StartEvent.parse(JSON.parse(trimmed)); + start = LegacyStartEvent.parse(JSON.parse(trimmed)); } else { // Subsequent lines - look for first user message or assistant response - const event = RunEvent.parse(JSON.parse(trimmed)); + const event = ReadRunEvent.parse(JSON.parse(trimmed)); if (event.type === 'message') { const msg = event.message; if (msg.role === 'user') { @@ -157,13 +178,15 @@ export class FSRunsRepo implements IRunsRepo { ); } - async create(options: z.infer): Promise> { + async create(options: CreateRunRepoOptions): Promise> { const runId = await this.idGenerator.next(); const ts = new Date().toISOString(); const start: z.infer = { type: "start", runId, agentName: options.agentId, + model: options.model, + provider: options.provider, subflow: [], ts, }; @@ -172,24 +195,41 @@ export class FSRunsRepo implements IRunsRepo { id: runId, createdAt: ts, agentId: options.agentId, + model: options.model, + provider: options.provider, log: [start], }; } async fetch(id: string): Promise> { const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8'); - const events = contents.split('\n') + // Parse with the lenient schema so legacy start events (no model/provider) load. + const rawEvents = contents.split('\n') .filter(line => line.trim() !== '') - .map(line => RunEvent.parse(JSON.parse(line))); - if (events.length === 0 || events[0].type !== 'start') { + .map(line => ReadRunEvent.parse(JSON.parse(line))); + if (rawEvents.length === 0 || rawEvents[0].type !== 'start') { throw new Error('Corrupt run data'); } + // Backfill model/provider on the start event from current defaults if missing, + // then promote to the canonical strict types for callers. + const rawStart = rawEvents[0]; + const defaults = (!rawStart.model || !rawStart.provider) + ? await getDefaultModelAndProvider() + : null; + const start: z.infer = { + ...rawStart, + model: rawStart.model ?? defaults!.model, + provider: rawStart.provider ?? defaults!.provider, + }; + const events: z.infer[] = [start, ...rawEvents.slice(1) as z.infer[]]; const title = this.extractTitle(events); return { id, title, - createdAt: events[0].ts!, - agentId: events[0].agentName, + createdAt: start.ts!, + agentId: start.agentName, + model: start.model, + provider: start.provider, log: events, }; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 8ea4688b..5b8395a9 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -10,11 +10,21 @@ import { IRunsLock } from "./lock.js"; import { forceCloseAllMcpClients } from "../mcp/mcp.js"; import { extractCommandNames } from "../application/lib/command-executor.js"; import { addToSecurityConfig } from "../config/security.js"; +import { loadAgent } from "../agents/runtime.js"; +import { getDefaultModelAndProvider } from "../models/defaults.js"; export async function createRun(opts: z.infer): Promise> { const repo = container.resolve('runsRepo'); const bus = container.resolve('bus'); - const run = await repo.create(opts); + + // Resolve model+provider once at creation: opts > agent declaration > defaults. + // Both fields are plain strings (provider is a name, looked up at runtime). + const agent = await loadAgent(opts.agentId); + const defaults = await getDefaultModelAndProvider(); + const model = opts.model ?? agent.model ?? defaults.model; + const provider = opts.provider ?? agent.provider ?? defaults.provider; + + const run = await repo.create({ agentId: opts.agentId, model, provider }); await bus.publish(run.log[0]); return run; } @@ -110,4 +120,4 @@ export async function fetchRun(runId: string): Promise> { export async function listRuns(cursor?: string): Promise> { const repo = container.resolve('runsRepo'); return repo.list(cursor); -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 2c1588e8..feec148f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const LlmProvider = z.object({ - flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]), + flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]), apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(), @@ -11,6 +11,15 @@ export const LlmModelConfig = z.object({ provider: LlmProvider, model: z.string(), models: z.array(z.string()).optional(), + providers: z.record(z.string(), z.object({ + apiKey: z.string().optional(), + baseURL: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + model: z.string().optional(), + models: z.array(z.string()).optional(), + })).optional(), + // Deprecated: per-run model+provider supersedes these. Kept on the schema so + // existing settings/onboarding UIs continue to compile until they're cleaned up. knowledgeGraphModel: z.string().optional(), meetingNotesModel: z.string().optional(), }); diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 5f52f611..2c5bcc7a 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -19,6 +19,8 @@ export const RunProcessingEndEvent = BaseRunEvent.extend({ export const StartEvent = BaseRunEvent.extend({ type: z.literal("start"), agentName: z.string(), + model: z.string(), + provider: z.string(), }); export const SpawnSubFlowEvent = BaseRunEvent.extend({ @@ -121,6 +123,8 @@ export const Run = z.object({ title: z.string().optional(), createdAt: z.iso.datetime(), agentId: z.string(), + model: z.string(), + provider: z.string(), log: z.array(RunEvent), }); @@ -134,6 +138,8 @@ export const ListRunsResponse = z.object({ nextCursor: z.string().optional(), }); -export const CreateRunOptions = Run.pick({ - agentId: true, -}); \ No newline at end of file +export const CreateRunOptions = z.object({ + agentId: z.string(), + model: z.string().optional(), + provider: z.string().optional(), +});