allow byok even when logged in

This commit is contained in:
Arjun 2026-07-05 16:46:32 +05:30
parent 0b55dc3300
commit c1fd4e6221
22 changed files with 452 additions and 221 deletions

View file

@ -1080,6 +1080,11 @@ export function setupIpcHandlers() {
await repo.setConfig(args);
return { success: true };
},
'models:updateConfig': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.updateConfig(args);
return { success: true };
},
'oauth:connect': async (_event, args) => {
const credentials = args.clientId && args.clientSecret
? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() }

View file

@ -400,8 +400,10 @@ function ChatInputInner({
return cleanup
}, [])
// Load the list of models the user can choose from.
// Signed-in: gateway model list. Signed-out: providers configured in models.json.
// Load the list of models the user can choose from. Hybrid mode: signed-in
// users get the gateway list AND every BYOK provider configured in
// models.json (selecting a BYOK model routes that message through the
// user's own key / local server). Signed-out users get BYOK only.
const loadModelConfig = useCallback(async () => {
// Concurrent runs race (mount fires one before the sign-in state resolves,
// which fires another) — only the newest run may write state, else a slow
@ -415,44 +417,37 @@ function ChatInputInner({
if (loadModelConfigEpoch.current === epoch) setDefaultModel(null)
}
try {
if (isRowboatConnected) {
const models: ConfiguredModel[] = []
const seen = new Set<string>()
const push = (provider: string, model: string) => {
if (!model) return
const key = `${provider}/${model}`
if (seen.has(key)) return
seen.add(key)
models.push({ provider: provider as ProviderName, model })
}
// Full catalog per provider (gateway + cloud). Providers with no
// catalog (Ollama, OpenAI-compatible) fall back to the models saved in
// config below.
const catalog: Record<string, string[]> = {}
try {
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 }) => ({ provider: 'rowboat', model: m.id })
)
if (loadModelConfigEpoch.current !== epoch) return
setConfiguredModels(models)
} else {
for (const p of listResult.providers || []) {
catalog[p.id] = (p.models || []).map((m: { id: string }) => m.id)
}
} catch { /* offline / no catalog — fall back to saved config below */ }
if (isRowboatConnected) {
for (const m of catalog['rowboat'] || []) push('rowboat', m)
}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' })
const parsed = JSON.parse(result.data)
// Offer every model the configured key supports — the same full catalog
// Settings uses for its dropdowns — so BYOK chat matches the signed-in
// gateway picker. The picker is no longer limited to a hand-curated
// config.models list. Providers with no catalog (Ollama, OpenAI-compatible)
// fall back to the model saved in config.
const catalog: Record<string, string[]> = {}
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)
}
} catch { /* offline / no catalog — fall back to saved config below */ }
const models: ConfiguredModel[] = []
const seen = new Set<string>()
const push = (provider: string, model: string) => {
if (!model) return
const key = `${provider}/${model}`
if (seen.has(key)) return
seen.add(key)
models.push({ provider: provider as ProviderName, model })
}
// List the default provider first so its default model leads the picker.
// List the default provider first so its default model leads the
// BYOK section of the picker.
const defaultFlavor = typeof parsed?.provider?.flavor === 'string' ? parsed.provider.flavor : ''
const flavors = Object.keys(parsed?.providers || {})
.sort((a, b) => (a === defaultFlavor ? -1 : b === defaultFlavor ? 1 : 0))
@ -474,9 +469,21 @@ function ChatInputInner({
for (const m of saved) push(flavor, m)
}
}
if (loadModelConfigEpoch.current !== epoch) return
setConfiguredModels(models)
}
// The user's explicit default selection leads the whole picker.
const sel = parsed?.defaultSelection
if (sel && typeof sel.provider === 'string' && typeof sel.model === 'string') {
const selKey = `${sel.provider}/${sel.model}`
const index = models.findIndex((m) => `${m.provider}/${m.model}` === selKey)
if (index > 0) {
const [entry] = models.splice(index, 1)
models.unshift(entry)
}
}
} catch { /* no BYOK config yet */ }
if (loadModelConfigEpoch.current !== epoch) return
setConfiguredModels(models)
} catch (err) {
// No config yet — but surface unexpected failures for diagnosis.
console.error('[chat-input] failed to load model list', err)

View file

@ -346,7 +346,7 @@ type ProviderModelConfig = {
autoPermissionDecisionModel: string
}
function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: boolean; rowboatConnected?: boolean }) {
const [provider, setProvider] = useState<LlmProviderFlavor>("openai")
const [defaultProvider, setDefaultProvider] = useState<LlmProviderFlavor | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, ProviderModelConfig>>({
@ -397,6 +397,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
useEffect(() => {
if (!dialogOpen) return
const asString = (v: unknown): string => (typeof v === "string" ? v : "")
async function loadCurrentConfig() {
try {
setConfigLoading(true)
@ -422,10 +424,10 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
apiKey: e.apiKey || "",
baseURL: e.baseURL || (defaultBaseURLs[key as LlmProviderFlavor] || ""),
models: savedModels,
knowledgeGraphModel: e.knowledgeGraphModel || "",
meetingNotesModel: e.meetingNotesModel || "",
liveNoteAgentModel: e.liveNoteAgentModel || "",
autoPermissionDecisionModel: e.autoPermissionDecisionModel || "",
knowledgeGraphModel: asString(e.knowledgeGraphModel),
meetingNotesModel: asString(e.meetingNotesModel),
liveNoteAgentModel: asString(e.liveNoteAgentModel),
autoPermissionDecisionModel: asString(e.autoPermissionDecisionModel),
};
}
}
@ -441,10 +443,10 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
apiKey: parsed.provider.apiKey || "",
baseURL: parsed.provider.baseURL || (defaultBaseURLs[flavor] || ""),
models: activeModels.length > 0 ? activeModels : [""],
knowledgeGraphModel: parsed.knowledgeGraphModel || "",
meetingNotesModel: parsed.meetingNotesModel || "",
liveNoteAgentModel: parsed.liveNoteAgentModel || "",
autoPermissionDecisionModel: parsed.autoPermissionDecisionModel || "",
knowledgeGraphModel: asString(parsed.knowledgeGraphModel),
meetingNotesModel: asString(parsed.meetingNotesModel),
liveNoteAgentModel: asString(parsed.liveNoteAgentModel),
autoPermissionDecisionModel: asString(parsed.autoPermissionDecisionModel),
};
}
return next;
@ -517,10 +519,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
},
model: allModels[0] || "",
models: allModels,
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
...(rowboatConnected ? {} : {
knowledgeGraphModel: activeConfig.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: activeConfig.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: activeConfig.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: activeConfig.autoPermissionDecisionModel.trim() || undefined,
}),
}
const result = await window.ipc.invoke("models:test", providerConfig)
if (result.success) {
@ -547,7 +551,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [canTest, provider, activeConfig])
}, [canTest, provider, activeConfig, rowboatConnected])
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
const config = providerConfigs[prov]
@ -562,10 +566,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
},
model: allModels[0],
models: allModels,
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
...(rowboatConnected ? {} : {
knowledgeGraphModel: config.knowledgeGraphModel.trim() || undefined,
meetingNotesModel: config.meetingNotesModel.trim() || undefined,
liveNoteAgentModel: config.liveNoteAgentModel.trim() || undefined,
autoPermissionDecisionModel: config.autoPermissionDecisionModel.trim() || undefined,
}),
})
setDefaultProvider(prov)
window.dispatchEvent(new Event('models-config-changed'))
@ -573,7 +579,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
} catch {
toast.error("Failed to set default provider")
}
}, [providerConfigs])
}, [providerConfigs, rowboatConnected])
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
try {
@ -594,10 +600,12 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
}
parsed.model = defModels[0] || ""
parsed.models = defModels
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
if (!rowboatConnected) {
parsed.knowledgeGraphModel = defConfig.knowledgeGraphModel.trim() || undefined
parsed.meetingNotesModel = defConfig.meetingNotesModel.trim() || undefined
parsed.liveNoteAgentModel = defConfig.liveNoteAgentModel.trim() || undefined
parsed.autoPermissionDecisionModel = defConfig.autoPermissionDecisionModel.trim() || undefined
}
}
await window.ipc.invoke("workspace:writeFile", {
path: "config/models.json",
@ -613,7 +621,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
} catch {
toast.error("Failed to remove provider")
}
}, [defaultProvider, providerConfigs])
}, [defaultProvider, providerConfigs, rowboatConnected])
const renderProviderCard = (p: { id: LlmProviderFlavor; name: string; description: string }) => {
const isDefault = defaultProvider === p.id
@ -635,7 +643,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium">{p.name}</span>
{isDefault && (
{isDefault && !rowboatConnected && (
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary">
Default
</span>
@ -644,16 +652,18 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="text-xs text-muted-foreground mt-0.5">{p.description}</div>
{!isDefault && hasModel && isSelected && (
<div className="mt-1.5 flex items-center gap-3">
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleSetDefault(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
Set as default
</span>
{!rowboatConnected && (
<span
role="button"
onClick={(e) => {
e.stopPropagation()
handleSetDefault(p.id)
}}
className="inline-flex text-[11px] text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
Set as default
</span>
)}
<span
role="button"
onClick={(e) => {
@ -705,7 +715,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
<div className="grid grid-cols-2 gap-3">
{/* Assistant models (left column) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assistant model</span>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{rowboatConnected ? "Model" : "Assistant model"}</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
@ -743,6 +753,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
)}
</div>
{!rowboatConnected && (<>
{/* Knowledge graph model (right column) */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Knowledge graph model</span>
@ -878,6 +889,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
</Select>
)}
</div>
</>)}
</div>
{/* API Key */}
@ -1262,11 +1274,45 @@ function ToolsLibrarySettings({ dialogOpen, rowboatConnected }: { dialogOpen: bo
}
// --- Rowboat Model Settings (when signed in via Rowboat) ---
//
// Hybrid mode: every dropdown lists the gateway catalog PLUS any models from
// BYOK providers configured below. Values are provider-qualified
// ("provider::model") and saved via models:updateConfig as {provider, model}
// refs, so a signed-in user can e.g. keep the gateway assistant while
// running background agents on a local Ollama model.
interface HybridModelOption {
provider: string
model: string
label: string
}
const providerDisplayNames: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Gemini',
ollama: 'Ollama',
openrouter: 'OpenRouter',
aigateway: 'AI Gateway',
'openai-compatible': 'OpenAI-Compatible',
rowboat: 'Rowboat',
}
const HYBRID_SEP = "::"
const hybridKey = (provider: string, model: string) => `${provider}${HYBRID_SEP}${model}`
function parseHybridKey(key: string): { provider: string; model: string } | null {
const index = key.indexOf(HYBRID_SEP)
if (index <= 0) return null
return { provider: key.slice(0, index), model: key.slice(index + HYBRID_SEP.length) }
}
function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [gatewayModels, setGatewayModels] = useState<LlmModelOption[]>([])
const [selectedModel, setSelectedModel] = useState("")
const [selectedKgModel, setSelectedKgModel] = useState("")
const [options, setOptions] = useState<HybridModelOption[]>([])
const [selectedDefault, setSelectedDefault] = useState("")
const [selectedKg, setSelectedKg] = useState("")
const [selectedLiveNote, setSelectedLiveNote] = useState("")
const [selectedAutoPermission, setSelectedAutoPermission] = useState("")
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@ -1276,22 +1322,63 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
async function load() {
setLoading(true)
try {
// 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 = rowboatProvider?.models || []
setGatewayModels(models)
const collected: HybridModelOption[] = []
const seen = new Set<string>()
const push = (provider: string, model: string, label?: string) => {
if (!model) return
const key = hybridKey(provider, model)
if (seen.has(key)) return
seen.add(key)
collected.push({ provider, model, label: label || model })
}
// Read current selection from config
const catalog: Record<string, LlmModelOption[]> = {}
try {
const listResult = await window.ipc.invoke("models:list", null)
for (const p of listResult.providers || []) {
catalog[p.id] = p.models || []
}
} catch { /* offline — BYOK entries below still load */ }
for (const m of catalog["rowboat"] || []) push("rowboat", m.id, m.name || m.id)
let parsed: Record<string, unknown> = {}
try {
const configResult = await window.ipc.invoke("workspace:readFile", { path: "config/models.json" })
const parsed = JSON.parse(configResult.data)
if (parsed?.model) setSelectedModel(parsed.model)
if (parsed?.knowledgeGraphModel) setSelectedKgModel(parsed.knowledgeGraphModel)
} catch {
// No config yet — pick first model as default
if (models.length > 0) setSelectedModel(models[0].id)
parsed = JSON.parse(configResult.data)
} catch { /* no BYOK config yet */ }
const providersMap = (parsed.providers ?? {}) as Record<string, Record<string, unknown>>
for (const [flavor, entry] of Object.entries(providersMap)) {
const hasKey = typeof entry.apiKey === "string" && (entry.apiKey as string).trim().length > 0
const hasBaseURL = typeof entry.baseURL === "string" && (entry.baseURL as string).trim().length > 0
if (!hasKey && !hasBaseURL) continue
push(flavor, typeof entry.model === "string" ? entry.model : "")
const catalogModels = catalog[flavor] || []
if (catalogModels.length > 0) {
for (const m of catalogModels) push(flavor, m.id, m.name || m.id)
} else {
for (const m of Array.isArray(entry.models) ? entry.models as string[] : []) push(flavor, m)
}
}
setOptions(collected)
// Current selections. Legacy string overrides pair with the BYOK
// top-level flavor (mirrors core/models/defaults.ts).
const legacyFlavor = (parsed.provider as Record<string, unknown> | undefined)?.flavor
const toKey = (value: unknown): string => {
if (!value) return ""
if (typeof value === "string") {
return typeof legacyFlavor === "string" ? hybridKey(legacyFlavor, value) : ""
}
const ref = value as { provider?: unknown; model?: unknown }
return typeof ref.provider === "string" && typeof ref.model === "string"
? hybridKey(ref.provider, ref.model)
: ""
}
setSelectedDefault(toKey(parsed.defaultSelection))
setSelectedKg(toKey(parsed.knowledgeGraphModel))
setSelectedLiveNote(toKey(parsed.liveNoteAgentModel))
setSelectedAutoPermission(toKey(parsed.autoPermissionDecisionModel))
} catch {
toast.error("Failed to load models")
} finally {
@ -1303,13 +1390,14 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
}, [dialogOpen])
const handleSave = useCallback(async () => {
if (!selectedModel) return
setSaving(true)
try {
await window.ipc.invoke("models:saveConfig", {
provider: { flavor: "openrouter" as const },
model: selectedModel,
knowledgeGraphModel: selectedKgModel || undefined,
const toRef = (key: string) => (key ? parseHybridKey(key) : null)
await window.ipc.invoke("models:updateConfig", {
defaultSelection: toRef(selectedDefault),
knowledgeGraphModel: toRef(selectedKg),
liveNoteAgentModel: toRef(selectedLiveNote),
autoPermissionDecisionModel: toRef(selectedAutoPermission),
})
window.dispatchEvent(new Event("models-config-changed"))
toast.success("Model configuration saved")
@ -1318,7 +1406,37 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
} finally {
setSaving(false)
}
}, [selectedModel, selectedKgModel])
}, [selectedDefault, selectedKg, selectedLiveNote, selectedAutoPermission])
const renderSelect = (
label: string,
value: string,
onChange: (v: string) => void,
defaultLabel: string,
) => (
<div className="space-y-2">
<label className="text-sm font-medium">{label}</label>
<Select value={value || "__default__"} onValueChange={(v) => onChange(v === "__default__" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder={defaultLabel} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">{defaultLabel}</SelectItem>
{options.map((o) => {
const key = hybridKey(o.provider, o.model)
return (
<SelectItem key={key} value={key}>
{o.label}
<span className="ml-2 text-xs text-muted-foreground">
{providerDisplayNames[o.provider] || o.provider}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
)
if (loading) {
return (
@ -1331,46 +1449,16 @@ function RowboatModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Select the models Rowboat uses. These are provided through your Rowboat account.
Select the models Rowboat uses. Rowboat models are provided through your account; models from your own providers route through your keys or local runtimes.
</p>
{/* Assistant model */}
<div className="space-y-2">
<label className="text-sm font-medium">Assistant model</label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Knowledge graph model */}
<div className="space-y-2">
<label className="text-sm font-medium">Knowledge graph model</label>
<Select value={selectedKgModel || "__same__"} onValueChange={(v) => setSelectedKgModel(v === "__same__" ? "" : v)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Same as assistant" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same__">Same as assistant</SelectItem>
{gatewayModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name || m.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{renderSelect("Assistant model", selectedDefault, setSelectedDefault, "Rowboat default")}
{renderSelect("Knowledge graph model", selectedKg, setSelectedKg, "Rowboat default")}
{renderSelect("Background agents model", selectedLiveNote, setSelectedLiveNote, "Rowboat default")}
{renderSelect("Permission checks model", selectedAutoPermission, setSelectedAutoPermission, "Rowboat default")}
{/* Save */}
<Button onClick={handleSave} disabled={!selectedModel || saving}>
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<><Loader2 className="size-4 animate-spin mr-2" />Saving...</>
) : (
@ -2111,7 +2199,9 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
})
}, [open])
const visibleTabs = useMemo(() => rowboatConnected ? tabs.filter(t => t.id !== "models") : tabs, [rowboatConnected])
// Hybrid mode: the Models tab is shown in both modes — signed-in users can
// pick gateway models AND bring their own providers/models alongside.
const visibleTabs = tabs
const activeTabConfig = visibleTabs.find((t) => t.id === activeTab) ?? visibleTabs[0]
const isJsonTab = activeTab === "mcp" || activeTab === "security"
@ -2252,7 +2342,19 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<MobileChannelsSettings dialogOpen={open} />
) : activeTab === "models" ? (
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />
? (
<div className="space-y-8">
<RowboatModelSettings dialogOpen={open} />
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Your own providers</h4>
<p className="text-xs text-muted-foreground">
Connect your own API keys or local runtimes (Ollama, LM Studio). Their models appear in the model pickers above and alongside your Rowboat models, and are billed to you directly.
</p>
<ModelSettings dialogOpen={open} rowboatConnected />
</div>
</div>
)
: <ModelSettings dialogOpen={open} />
) : activeTab === "note-tagging" ? (
<NoteTaggingSettings dialogOpen={open} />

View file

@ -2,7 +2,6 @@ import type { EventConsumer, EventConsumerTarget } from '../events/consumer.js';
import { routeBatch } from '../events/routing.js';
import { createLanguageModel } from '../models/models.js';
import {
getDefaultModelAndProvider,
getBackgroundTaskAgentModel,
resolveProviderConfig,
} from '../models/defaults.js';
@ -10,8 +9,7 @@ import { listTasks } from './fileops.js';
import { runBackgroundTask } from './runner.js';
async function resolveRoutingModel() {
const modelId = await getBackgroundTaskAgentModel();
const { provider } = await getDefaultModelAndProvider();
const { model: modelId, provider } = await getBackgroundTaskAgentModel();
const config = await resolveProviderConfig(provider);
return {
model: createLanguageModel(config, modelId, { priority: 'classifier' }),

View file

@ -137,7 +137,12 @@ export async function runBackgroundTask(
}
}
const model = task.model || await getBackgroundTaskAgentModel();
// task.yaml model/provider win; otherwise the category default
// (provider-qualified in hybrid mode). A task model without a
// provider keeps the legacy meaning: the app-default provider.
const selection = await getBackgroundTaskAgentModel();
const model = task.model || selection.model;
const provider = task.provider ?? (task.model ? undefined : selection.provider);
// Establish the use-case context for the whole turn so every tool the
// agent calls (notably notify-user) reads `background_task_agent` via
// getCurrentUseCase(); the AsyncLocalStorage context set here flows
@ -148,7 +153,7 @@ export async function runBackgroundTask(
agentId: 'background-task-agent',
message: buildMessage(slug, task, trigger, context, codeProject),
model,
...(task.provider ? { provider: task.provider } : {}),
...(provider ? { provider } : {}),
throwOnError: true,
}),
);

View file

@ -284,7 +284,7 @@ async function processAgentNotes(): Promise<void> {
await runHeadlessAgent({
agentId: AGENT_ID,
message,
model: await getKgModel(),
...(await getKgModel()),
throwOnError: true,
});

View file

@ -388,7 +388,7 @@ async function createNotesFromBatch(
const { turnId, state } = await runHeadlessAgent({
agentId: NOTE_CREATION_AGENT,
message,
model: await getKgModel(),
...(await getKgModel()),
throwOnError: true,
});
@ -946,7 +946,7 @@ export async function curateNotes(): Promise<void> {
await runHeadlessAgent({
agentId: CURATION_AGENT,
message,
model: await getKgModel(),
...(await getKgModel()),
throwOnError: true,
});
curated++;

View file

@ -7,7 +7,6 @@ import { WorkDir } from '../config/config.js';
import { createLanguageModel } from '../models/models.js';
import { generateObjectSafe } from '../models/structured.js';
import {
getDefaultModelAndProvider,
getKgModel,
resolveProviderConfig,
} from '../models/defaults.js';
@ -246,8 +245,7 @@ export async function classifyThread(
// (no-ops unless enough new corrections exist).
await maybeDistillImportanceRules();
const modelId = await getKgModel();
const { provider } = await getDefaultModelAndProvider();
const { model: modelId, provider } = await getKgModel();
const config = await resolveProviderConfig(provider);
const model = createLanguageModel(config, modelId, { priority: 'background' });

View file

@ -1,10 +1,10 @@
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { generateObject } from 'ai';
import { WorkDir } from '../config/config.js';
import { createProvider } from '../models/models.js';
import { getDefaultModelAndProvider, getKgModel, resolveProviderConfig } from '../models/defaults.js';
import { createLanguageModel } from '../models/models.js';
import { generateObjectSafe } from '../models/structured.js';
import { getKgModel, resolveProviderConfig } from '../models/defaults.js';
import { captureLlmUsage } from '../analytics/usage.js';
import { withUseCase } from '../analytics/use_case.js';
@ -145,21 +145,21 @@ export async function maybeDistillImportanceRules(): Promise<void> {
if (newSince <= 0) return;
try {
const modelId = await getKgModel();
const { provider } = await getDefaultModelAndProvider();
const { model: modelId, provider } = await getKgModel();
const config = await resolveProviderConfig(provider);
const model = createProvider(config).languageModel(modelId);
const model = createLanguageModel(config, modelId, { priority: 'background' });
const correctionLines = fb.corrections.map(c =>
`- From: ${c.from} | Subject: "${c.subject}" | classifier said ${c.agentVerdict}, user corrected to ${c.userVerdict}`
).join('\n');
const existingRules = fb.rules.length ? `\n\nCurrent rules (rewrite/merge as needed):\n${fb.rules.map(r => `- ${r}`).join('\n')}` : '';
const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'importance_rule_distiller' }, () => generateObject({
const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'importance_rule_distiller' }, () => generateObjectSafe({
model,
system: `You maintain a short list of email-importance preference rules for one user, derived from their explicit corrections of an automated classifier. Write at most ${MAX_RULES} rules. Rules must GENERALIZE (sender domains, email types, topics) — never restate a single thread. Where corrections conflict, prefer the more recent. Keep rules that are still supported; drop ones the corrections no longer support.`,
prompt: `Corrections (oldest first):\n${correctionLines}${existingRules}`,
schema: DistilledRules,
retry: true,
}));
captureLlmUsage({

View file

@ -483,7 +483,7 @@ async function processInlineTasks(): Promise<void> {
const { summary: result } = await runHeadlessAgent({
agentId: INLINE_TASK_AGENT,
message,
model: await getKgModel(),
...(await getKgModel()),
});
if (result) {
if (task.targetId) {
@ -562,7 +562,7 @@ export async function processRowboatInstruction(
const { summary: rawResponse } = await runHeadlessAgent({
agentId: INLINE_TASK_AGENT,
message,
model: await getKgModel(),
...(await getKgModel()),
});
if (!rawResponse) {
return { instruction, schedule: null, scheduleLabel: null, response: null };

View file

@ -87,7 +87,7 @@ async function labelEmailBatch(
const { turnId, state } = await runHeadlessAgent({
agentId: LABELING_AGENT,
message,
model: await getKgModel(),
...(await getKgModel()),
throwOnError: true,
});

View file

@ -4,11 +4,10 @@ import { runLiveNoteAgent } from './runner.js';
import type { EventConsumer, EventConsumerTarget } from '../../events/consumer.js';
import { routeBatch } from '../../events/routing.js';
import { createLanguageModel } from '../../models/models.js';
import { getDefaultModelAndProvider, getLiveNoteAgentModel, resolveProviderConfig } from '../../models/defaults.js';
import { getLiveNoteAgentModel, resolveProviderConfig } from '../../models/defaults.js';
async function resolveRoutingModel() {
const modelId = await getLiveNoteAgentModel();
const { provider } = await getDefaultModelAndProvider();
const { model: modelId, provider } = await getLiveNoteAgentModel();
const config = await resolveProviderConfig(provider);
return {
model: createLanguageModel(config, modelId, { priority: 'classifier' }),

View file

@ -108,7 +108,12 @@ export async function runLiveNoteAgent(
const bodyBefore = await readNoteBody(filePath);
const model = live.model ?? await getLiveNoteAgentModel();
// Note-frontmatter model/provider win; otherwise the category default
// (provider-qualified in hybrid mode). A frontmatter model without a
// provider keeps the legacy meaning: the app-default provider.
const selection = await getLiveNoteAgentModel();
const model = live.model ?? selection.model;
const provider = live.provider ?? (live.model ? undefined : selection.provider);
// The use-case context propagates to every tool the agent calls; the
// granular trigger doubles as the sub-use-case (manual / cron /
// window / event) so dashboards can break down what woke the agent.
@ -118,7 +123,7 @@ export async function runLiveNoteAgent(
agentId: 'live-note-agent',
message: buildMessage(filePath, live, trigger, context),
model,
...(live.provider ? { provider: live.provider } : {}),
...(provider ? { provider } : {}),
throwOnError: true,
}),
);

View file

@ -3,7 +3,7 @@ import path from 'node:path';
import { generateText } from 'ai';
import { WorkDir } from '../config/config.js';
import { createLanguageModel } from '../models/models.js';
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
import { getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
import { captureLlmUsage } from '../analytics/usage.js';
import { withUseCase } from '../analytics/use_case.js';
import { parseFrontmatter } from '../application/lib/parse-frontmatter.js';
@ -175,8 +175,7 @@ async function generateBrief(event: CalendarEvent, ctx: Awaited<ReturnType<typeo
});
if (attendeeLines.length) parts.push(`Attendees:\n${attendeeLines.join('\n')}`);
const modelId = await getMeetingNotesModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const { model: modelId, provider: providerName } = await getMeetingNotesModel();
const providerConfig = await resolveProviderConfig(providerName);
const model = createLanguageModel(providerConfig, modelId, { priority: 'background' });

View file

@ -2,7 +2,7 @@ import fs from 'fs';
import path from 'path';
import { generateText } from 'ai';
import { createLanguageModel } from '../models/models.js';
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
import { getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
import { WorkDir } from '../config/config.js';
import { captureLlmUsage } from '../analytics/usage.js';
import { withUseCase } from '../analytics/use_case.js';
@ -137,8 +137,7 @@ function loadCalendarEventContext(calendarEventJson: string): string {
}
export async function summarizeMeeting(transcript: string, meetingStartTime?: string, calendarEventJson?: string): Promise<string> {
const modelId = await getMeetingNotesModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const { model: modelId, provider: providerName } = await getMeetingNotesModel();
const providerConfig = await resolveProviderConfig(providerName);
const model = createLanguageModel(providerConfig, modelId, { priority: 'background' });

View file

@ -100,7 +100,7 @@ async function tagNoteBatch(
const { turnId, state } = await runHeadlessAgent({
agentId: NOTE_TAGGING_AGENT,
message,
model: await getKgModel(),
...(await getKgModel()),
throwOnError: true,
});

View file

@ -1,5 +1,5 @@
import z from "zod";
import { LlmProvider } from "@x/shared/dist/models.js";
import { LlmModelConfig, LlmProvider, ModelRef } from "@x/shared/dist/models.js";
import { IModelConfigRepo } from "./repo.js";
import { isSignedIn } from "../account/account.js";
import container from "../di/container.js";
@ -15,17 +15,42 @@ const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL = "google/gemini-3.1-flash-lite";
export type ModelSelection = z.infer<typeof ModelRef>;
async function readConfig(): Promise<z.infer<typeof LlmModelConfig> | null> {
try {
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
return await repo.getConfig();
} catch {
// Signed-in users may have no models.json at all.
return null;
}
}
/**
* 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.
* the caller didn't specify and the agent didn't declare".
*
* Resolution order (hybrid mode):
* 1. `defaultSelection` the user's explicit choice; may point at the
* gateway ("rowboat") or any BYOK provider, and is honored in both modes
* (a "rowboat" selection is skipped while signed out it needs auth).
* 2. Signed in the curated gateway default.
* 3. BYOK the legacy top-level provider/model pair.
*/
export async function getDefaultModelAndProvider(): Promise<{ model: string; provider: string }> {
if (await isSignedIn()) {
const signedIn = await isSignedIn();
const cfg = await readConfig();
const selection = cfg?.defaultSelection;
if (selection && (selection.provider !== "rowboat" || signedIn)) {
return { model: selection.model, provider: selection.provider };
}
if (signedIn) {
return { model: SIGNED_IN_DEFAULT_MODEL, provider: SIGNED_IN_DEFAULT_PROVIDER };
}
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
const cfg = await repo.getConfig();
if (!cfg) {
throw new Error("No model configuration found (models.json missing and not signed in)");
}
return { model: cfg.model, provider: cfg.provider.flavor };
}
@ -62,48 +87,60 @@ export async function resolveProviderConfig(name: string): Promise<z.infer<typeo
throw new Error(`Provider '${name}' is referenced but not configured`);
}
// Per-category model resolution (hybrid mode):
// 1. An explicit override wins in BOTH modes. Provider-qualified refs are
// used as-is (a "rowboat" ref is skipped while signed out); legacy string
// overrides pair with the BYOK provider they were configured against
// (the top-level flavor), NOT the dynamic default — so a signed-in user's
// local-model overrides keep routing to their local server.
// 2. No override, signed in → the curated gateway model.
// 3. No override, BYOK → the assistant default.
async function getCategoryModel(
category: "knowledgeGraphModel" | "meetingNotesModel" | "liveNoteAgentModel" | "autoPermissionDecisionModel",
curatedModel: string,
): Promise<ModelSelection> {
const signedIn = await isSignedIn();
const cfg = await readConfig();
const override = cfg?.[category];
if (override) {
if (typeof override === "string") {
if (cfg) {
return { model: override, provider: cfg.provider.flavor };
}
} else if (override.provider !== "rowboat" || signedIn) {
return { model: override.model, provider: override.provider };
}
}
if (signedIn) {
return { model: curatedModel, provider: SIGNED_IN_DEFAULT_PROVIDER };
}
return getDefaultModelAndProvider();
}
/**
* Model used by knowledge-graph agents (note_creation, labeling_agent, etc.)
* when they're the top-level of a run. Signed-in: curated default.
* BYOK: user override (`knowledgeGraphModel`) or assistant model.
* when they're the top-level of a run.
*/
export async function getKgModel(): Promise<string> {
if (await isSignedIn()) return SIGNED_IN_KG_MODEL;
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
return cfg.knowledgeGraphModel ?? cfg.model;
export async function getKgModel(): Promise<ModelSelection> {
return getCategoryModel("knowledgeGraphModel", SIGNED_IN_KG_MODEL);
}
/** Model used by the live-note agent + routing classifier. */
export async function getLiveNoteAgentModel(): Promise<ModelSelection> {
return getCategoryModel("liveNoteAgentModel", SIGNED_IN_LIVE_NOTE_AGENT_MODEL);
}
/** Model used by the auto-permission classifier. */
export async function getAutoPermissionDecisionModel(): Promise<ModelSelection> {
return getCategoryModel("autoPermissionDecisionModel", SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL);
}
/**
* Model used by the live-note agent + routing classifier.
* Signed-in: curated default. BYOK: user override (`liveNoteAgentModel`) or
* assistant model.
* Model used by the meeting-notes summarizer. No special signed-in curated
* model historically meetings used the assistant model.
*/
export async function getLiveNoteAgentModel(): Promise<string> {
if (await isSignedIn()) return SIGNED_IN_LIVE_NOTE_AGENT_MODEL;
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
return cfg.liveNoteAgentModel ?? cfg.model;
}
/**
* Model used by the auto-permission classifier.
* Signed-in: curated default. BYOK: user override
* (`autoPermissionDecisionModel`) or assistant model.
*/
export async function getAutoPermissionDecisionModel(): Promise<string> {
if (await isSignedIn()) return SIGNED_IN_AUTO_PERMISSION_DECISION_MODEL;
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
return cfg.autoPermissionDecisionModel ?? cfg.model;
}
/**
* Model used by the meeting-notes summarizer. No special signed-in default
* historically meetings used the assistant model. BYOK: user override
* (`meetingNotesModel`) or assistant model.
*/
export async function getMeetingNotesModel(): Promise<string> {
if (await isSignedIn()) return SIGNED_IN_DEFAULT_MODEL;
const cfg = await container.resolve<IModelConfigRepo>("modelConfigRepo").getConfig();
return cfg.meetingNotesModel ?? cfg.model;
export async function getMeetingNotesModel(): Promise<ModelSelection> {
return getCategoryModel("meetingNotesModel", SIGNED_IN_DEFAULT_MODEL);
}
/**
@ -112,6 +149,6 @@ export async function getMeetingNotesModel(): Promise<string> {
* agent model. Split into its own getter so a future per-feature override
* doesn't require touching all call sites.
*/
export async function getBackgroundTaskAgentModel(): Promise<string> {
export async function getBackgroundTaskAgentModel(): Promise<ModelSelection> {
return getLiveNoteAgentModel();
}

View file

@ -4,10 +4,24 @@ import fs from "fs/promises";
import path from "path";
import z from "zod";
export type ModelConfigPatch = {
[K in
| "defaultSelection"
| "knowledgeGraphModel"
| "meetingNotesModel"
| "liveNoteAgentModel"
| "autoPermissionDecisionModel"]?: z.infer<typeof ModelConfig>[K] | null;
};
export interface IModelConfigRepo {
ensureConfig(): Promise<void>;
getConfig(): Promise<z.infer<typeof ModelConfig>>;
setConfig(config: z.infer<typeof ModelConfig>): Promise<void>;
// Merge the given top-level keys into the existing file without touching
// provider credentials — hybrid settings (default selection, category
// overrides) save through this. Omitted keys are untouched; an explicit
// null clears the key back to its default.
updateConfig(patch: ModelConfigPatch): Promise<void>;
}
const defaultConfig: z.infer<typeof ModelConfig> = {
@ -63,7 +77,37 @@ export class FSModelConfigRepo implements IModelConfigRepo {
autoPermissionDecisionModel: config.autoPermissionDecisionModel,
};
const toWrite = { ...config, providers: existingProviders };
// saveConfig owns provider credentials/model lists; the hybrid-mode
// selections are owned by updateConfig — carry them over when the
// incoming config doesn't set them.
let existingSelections: Record<string, unknown> = {};
try {
const raw = await fs.readFile(this.configPath, "utf8");
const existing = JSON.parse(raw);
existingSelections = Object.fromEntries(
["defaultSelection", "knowledgeGraphModel", "meetingNotesModel", "liveNoteAgentModel", "autoPermissionDecisionModel"]
.filter((key) => existing[key] !== undefined && (config as Record<string, unknown>)[key] === undefined)
.map((key) => [key, existing[key]]),
);
} catch {
// No existing config
}
const toWrite = { ...existingSelections, ...config, providers: existingProviders };
await fs.writeFile(this.configPath, JSON.stringify(toWrite, null, 2));
}
async updateConfig(patch: ModelConfigPatch): Promise<void> {
const raw = await fs.readFile(this.configPath, "utf8");
const existing = JSON.parse(raw) as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value === undefined || value === null) {
delete existing[key];
} else {
existing[key] = value;
}
}
ModelConfig.parse(existing);
await fs.writeFile(this.configPath, JSON.stringify(existing, null, 2));
}
}

View file

@ -55,7 +55,7 @@ Process new items and use the user context above to identify yourself when draft
await runHeadlessAgent({
agentId: agentName,
message,
model: await getKgModel(),
...(await getKgModel()),
});
// Update last run time

View file

@ -4,7 +4,7 @@ import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { captureLlmUsage } from "../analytics/usage.js";
import { withUseCase, type UseCase } from "../analytics/use_case.js";
import { getAutoPermissionDecisionModel, getDefaultModelAndProvider, resolveProviderConfig } from "../models/defaults.js";
import { getAutoPermissionDecisionModel, resolveProviderConfig } from "../models/defaults.js";
import { createLanguageModel } from "../models/models.js";
import { generateObjectSafe } from "../models/structured.js";
@ -81,8 +81,7 @@ export async function classifyToolPermissions(input: {
}): Promise<AutoPermissionDecision[]> {
if (input.candidates.length === 0) return [];
const modelId = await getAutoPermissionDecisionModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const { model: modelId, provider: providerName } = await getAutoPermissionDecisionModel();
const providerConfig = await resolveProviderConfig(providerName);
const model = createLanguageModel(providerConfig, modelId, { priority: "classifier" });

View file

@ -2,7 +2,7 @@ import { z } from 'zod';
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
import { ListToolsResponse } from './mcp.js';
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
import { LlmModelConfig, LlmProvider } from './models.js';
import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
import { ServiceEvent } from './service-events.js';
@ -612,6 +612,22 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// Partial top-level merge into models.json — used by hybrid (signed-in +
// BYOK) settings to set the default selection / category overrides without
// clobbering the BYOK provider config that saveConfig owns. Omitted keys
// are untouched; null clears a key back to its default.
'models:updateConfig': {
req: z.object({
defaultSelection: ModelRef.nullable().optional(),
knowledgeGraphModel: ModelOverride.nullable().optional(),
meetingNotesModel: ModelOverride.nullable().optional(),
liveNoteAgentModel: ModelOverride.nullable().optional(),
autoPermissionDecisionModel: ModelOverride.nullable().optional(),
}),
res: z.object({
success: z.literal(true),
}),
},
'oauth:connect': {
req: z.object({
provider: z.string(),

View file

@ -16,10 +16,27 @@ export const LlmProvider = z.object({
reasoningEffort: z.enum(["low", "medium", "high"]).optional(),
});
// A provider-qualified model reference. `provider` is a provider name as
// understood by resolveProviderConfig — a BYOK flavor ("ollama", "openai",
// …) or "rowboat" for the signed-in gateway.
export const ModelRef = z.object({
provider: z.string(),
model: z.string(),
});
// Category overrides accept either a bare model id (legacy: paired with the
// active default provider) or a provider-qualified ref (hybrid mode: e.g.
// gateway assistant + local Ollama background agents).
export const ModelOverride = z.union([z.string(), ModelRef]);
export const LlmModelConfig = z.object({
provider: LlmProvider,
model: z.string(),
models: z.array(z.string()).optional(),
// The user's explicit default assistant model. When set it wins over both
// the signed-in curated default and the legacy top-level provider/model
// pair — this is what lets signed-in users default to a BYOK model.
defaultSelection: ModelRef.optional(),
providers: z.record(z.string(), z.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
@ -33,10 +50,11 @@ export const LlmModelConfig = z.object({
liveNoteAgentModel: z.string().optional(),
autoPermissionDecisionModel: z.string().optional(),
})).optional(),
// Per-category model overrides (BYOK only — signed-in users always get
// the curated gateway defaults). Read by helpers in core/models/defaults.ts.
knowledgeGraphModel: z.string().optional(),
meetingNotesModel: z.string().optional(),
liveNoteAgentModel: z.string().optional(),
autoPermissionDecisionModel: z.string().optional(),
// Per-category model overrides. Honored in both modes: when unset,
// signed-in users get the curated gateway defaults and BYOK users get the
// assistant model. Read by helpers in core/models/defaults.ts.
knowledgeGraphModel: ModelOverride.optional(),
meetingNotesModel: ModelOverride.optional(),
liveNoteAgentModel: ModelOverride.optional(),
autoPermissionDecisionModel: ModelOverride.optional(),
});