mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
commit
092f6e0d1a
37 changed files with 1368 additions and 285 deletions
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -434,10 +434,29 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
|
|||
}
|
||||
|
||||
const catalog: string[] = result.models ?? []
|
||||
const typed = activeConfig.model.trim()
|
||||
// Hosted providers hide the model field (it holds an auto-seeded
|
||||
// default), so only treat it as user intent where the field is shown —
|
||||
// mirrors showModelInput in llm-setup-step.
|
||||
const hostedProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
const modelInputShown = !hostedProviders.includes(llmProvider)
|
||||
|
||||
if (modelInputShown && typed && llmProvider === "ollama" && catalog.length > 0 && !catalog.includes(typed)) {
|
||||
// Ollama's tag list is authoritative: an unlisted model isn't pulled,
|
||||
// so saving it would break chat at runtime with no obvious cause.
|
||||
const error = `Model '${typed}' is not available on this Ollama server. Pull it first (ollama pull ${typed}) or pick one of: ${catalog.slice(0, 5).join(", ")}${catalog.length > 5 ? ", …" : ""}`
|
||||
setTestState({ status: "error", error })
|
||||
toast.error(error)
|
||||
return false
|
||||
}
|
||||
|
||||
const preferred = preferredDefaults[llmProvider]
|
||||
const model =
|
||||
(preferred && catalog.includes(preferred) && preferred) ||
|
||||
catalog[0] || activeConfig.model.trim() || ""
|
||||
// A model the user explicitly entered always wins — this used to prefer
|
||||
// catalog[0], which silently replaced the user's Ollama model with
|
||||
// whatever model the local server happened to list first.
|
||||
const model = modelInputShown
|
||||
? (typed || catalog[0] || "")
|
||||
: ((preferred && catalog.includes(preferred) && preferred) || catalog[0] || typed || "")
|
||||
|
||||
// `models` is the user's curated assistant-model list (shown in Settings),
|
||||
// NOT the full provider catalog. Onboarding seeds it with just the selected
|
||||
|
|
|
|||
|
|
@ -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>>({
|
||||
|
|
@ -364,6 +364,11 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({ status: "idle" })
|
||||
const [configLoading, setConfigLoading] = useState(true)
|
||||
const [showMoreProviders, setShowMoreProviders] = useState(false)
|
||||
// "Defer background tasks while a chat is running" — a top-level
|
||||
// models.json flag. deferExplicit tracks whether the user (or the Ollama
|
||||
// auto-enable) has ever set it, so we only auto-enable once.
|
||||
const [deferBackgroundTasks, setDeferBackgroundTasks] = useState(false)
|
||||
const [deferExplicit, setDeferExplicit] = useState(false)
|
||||
|
||||
const activeConfig = providerConfigs[provider]
|
||||
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
|
||||
|
|
@ -397,6 +402,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)
|
||||
|
|
@ -404,6 +411,8 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
path: "config/models.json",
|
||||
})
|
||||
const parsed = JSON.parse(result.data)
|
||||
setDeferBackgroundTasks(parsed?.deferBackgroundTasks === true)
|
||||
setDeferExplicit(typeof parsed?.deferBackgroundTasks === "boolean")
|
||||
if (parsed?.provider?.flavor && parsed?.model) {
|
||||
const flavor = parsed.provider.flavor as LlmProviderFlavor
|
||||
setProvider(flavor)
|
||||
|
|
@ -422,10 +431,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 +450,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;
|
||||
|
|
@ -460,6 +469,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
loadCurrentConfig()
|
||||
}, [dialogOpen])
|
||||
|
||||
const handleDeferToggle = useCallback(async (value: boolean) => {
|
||||
setDeferBackgroundTasks(value)
|
||||
setDeferExplicit(true)
|
||||
try {
|
||||
await window.ipc.invoke("models:updateConfig", { deferBackgroundTasks: value })
|
||||
window.dispatchEvent(new Event("models-config-changed"))
|
||||
} catch {
|
||||
toast.error("Failed to save setting")
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load models catalog
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
|
|
@ -517,10 +537,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) {
|
||||
|
|
@ -528,7 +550,23 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
setDefaultProvider(provider)
|
||||
setTestState({ status: "success" })
|
||||
window.dispatchEvent(new Event('models-config-changed'))
|
||||
toast.success("Model configuration saved")
|
||||
// Local models compete with background agents for the same hardware:
|
||||
// when the user connects Ollama and has never touched the defer
|
||||
// flag, enable it for them (they can switch it off below).
|
||||
if (provider === "ollama" && !deferExplicit && !deferBackgroundTasks) {
|
||||
void handleDeferToggle(true)
|
||||
}
|
||||
// Capability probe caveats (local models): saved, but the user should
|
||||
// know when the model can't do tools or has a too-small context.
|
||||
const warnings: string[] = result.warnings ?? []
|
||||
if (warnings.length > 0) {
|
||||
for (const warning of warnings) {
|
||||
toast.warning(warning, { duration: 12000 })
|
||||
}
|
||||
toast.success("Model configuration saved (with warnings)")
|
||||
} else {
|
||||
toast.success("Model configuration saved")
|
||||
}
|
||||
} else {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
|
|
@ -537,7 +575,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, deferExplicit, deferBackgroundTasks, handleDeferToggle])
|
||||
|
||||
const handleSetDefault = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
const config = providerConfigs[prov]
|
||||
|
|
@ -552,10 +590,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'))
|
||||
|
|
@ -563,7 +603,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
} catch {
|
||||
toast.error("Failed to set default provider")
|
||||
}
|
||||
}, [providerConfigs])
|
||||
}, [providerConfigs, rowboatConnected])
|
||||
|
||||
const handleDeleteProvider = useCallback(async (prov: LlmProviderFlavor) => {
|
||||
try {
|
||||
|
|
@ -584,10 +624,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",
|
||||
|
|
@ -603,7 +645,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
|
||||
|
|
@ -625,7 +667,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>
|
||||
|
|
@ -634,16 +676,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) => {
|
||||
|
|
@ -695,7 +739,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" />
|
||||
|
|
@ -733,6 +777,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>
|
||||
|
|
@ -868,6 +913,7 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
|
|
@ -916,6 +962,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Defer background tasks while chatting */}
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">Defer background tasks while chatting</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Background agents (knowledge sync, live notes, tasks) wait until your chat finishes. Recommended for local models.
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={deferBackgroundTasks} onCheckedChange={handleDeferToggle} />
|
||||
</div>
|
||||
|
||||
{/* Test & Save button */}
|
||||
<Button
|
||||
onClick={handleTestAndSave}
|
||||
|
|
@ -1252,11 +1309,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)
|
||||
|
||||
|
|
@ -1266,22 +1357,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 {
|
||||
|
|
@ -1293,13 +1425,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")
|
||||
|
|
@ -1308,7 +1441,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 (
|
||||
|
|
@ -1321,46 +1484,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...</>
|
||||
) : (
|
||||
|
|
@ -2101,7 +2234,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"
|
||||
|
|
@ -2242,7 +2377,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} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { IAgentScheduleRepo } from "./repo.js";
|
|||
import { IAgentScheduleStateRepo } from "./state-repo.js";
|
||||
import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js";
|
||||
import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js";
|
||||
import { startHeadlessAgent } from "../agents/headless-app.js";
|
||||
import { startWhenPossible } from "../agents/headless-app.js";
|
||||
import { withUseCase } from "../analytics/use_case.js";
|
||||
import z from "zod";
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ async function runAgent(
|
|||
const startingMessage = entry.startingMessage ?? DEFAULT_STARTING_MESSAGE;
|
||||
const handle = await withUseCase(
|
||||
{ useCase: 'copilot_chat', subUseCase: 'scheduled' },
|
||||
() => startHeadlessAgent({
|
||||
() => startWhenPossible({
|
||||
agentId: agentName,
|
||||
message: startingMessage,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import container from "../di/container.js";
|
||||
import { chatActivity } from "../application/lib/chat-activity.js";
|
||||
import { shouldDeferBackgroundTasks } from "../models/defaults.js";
|
||||
import {
|
||||
type HeadlessAgentHandle,
|
||||
type HeadlessAgentOptions,
|
||||
|
|
@ -22,4 +24,30 @@ export function runHeadlessAgent(
|
|||
return runner().run(options);
|
||||
}
|
||||
|
||||
// When the user enabled "Defer background tasks while a chat is running"
|
||||
// (recommended for local models — a background run competes with the chat
|
||||
// for the same hardware), wait for the chat to go idle before starting.
|
||||
// Re-check after each wake: another chat turn may have started meanwhile.
|
||||
async function waitForChatIdleIfConfigured(): Promise<void> {
|
||||
while ((await shouldDeferBackgroundTasks()) && chatActivity.activeCount > 0) {
|
||||
await chatActivity.waitUntilIdle();
|
||||
}
|
||||
}
|
||||
|
||||
/** startHeadlessAgent for background work: honors the defer-while-chatting setting. */
|
||||
export async function startWhenPossible(
|
||||
options: HeadlessAgentOptions,
|
||||
): Promise<HeadlessAgentHandle> {
|
||||
await waitForChatIdleIfConfigured();
|
||||
return startHeadlessAgent(options);
|
||||
}
|
||||
|
||||
/** runHeadlessAgent for background work: honors the defer-while-chatting setting. */
|
||||
export async function runWhenPossible(
|
||||
options: HeadlessAgentOptions,
|
||||
): Promise<HeadlessAgentResult & { turnId: string }> {
|
||||
await waitForChatIdleIfConfigured();
|
||||
return runHeadlessAgent(options);
|
||||
}
|
||||
|
||||
export { toolInputPaths } from "./headless.js";
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import { resolveFilePathForPermission } from "../filesystem/files.js";
|
|||
import container from "../di/container.js";
|
||||
import { notifyIfEnabled } from "../application/notification/notifier.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
import { createLanguageModel } from "../models/models.js";
|
||||
import { chatActivity } from "../application/lib/chat-activity.js";
|
||||
import { resolveProviderConfig } from "../models/defaults.js";
|
||||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
|
|
@ -496,6 +497,9 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
return;
|
||||
}
|
||||
const signal = this.abortRegistry.createForRun(runId);
|
||||
// Legacy runs are user-facing chats: mark activity so background
|
||||
// agents can defer (see agents/headless-app.ts runWhenPossible).
|
||||
chatActivity.enter();
|
||||
try {
|
||||
await this.bus.publish({
|
||||
runId,
|
||||
|
|
@ -610,6 +614,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
await this.runsRepo.appendEvents(runId, [errorEvent]);
|
||||
await this.bus.publish(errorEvent);
|
||||
} finally {
|
||||
chatActivity.exit();
|
||||
this.abortRegistry.cleanup(runId);
|
||||
await this.runsLock.release(runId);
|
||||
await this.bus.publish({
|
||||
|
|
@ -1365,8 +1370,7 @@ export async function* streamAgent({
|
|||
}
|
||||
const modelId = state.runModel;
|
||||
const providerConfig = await resolveProviderConfig(state.runProvider);
|
||||
const provider = createProvider(providerConfig);
|
||||
const model = provider.languageModel(modelId);
|
||||
const model = createLanguageModel(providerConfig, modelId);
|
||||
logger.log(`using model: ${modelId} (provider: ${state.runProvider})`);
|
||||
|
||||
// Install use-case context for tool-internal LLM calls (e.g. parseFile)
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ async function resolveCodeProject(dirPath: string): Promise<
|
|||
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
import { generateText } from "ai";
|
||||
import { createProvider } from "../../models/models.js";
|
||||
import { createLanguageModel } from "../../models/models.js";
|
||||
import { getDefaultModelAndProvider, resolveProviderConfig } from "../../models/defaults.js";
|
||||
import { captureLlmUsage } from "../../analytics/usage.js";
|
||||
import { getCurrentUseCase, withUseCase } from "../../analytics/use_case.js";
|
||||
|
|
@ -584,7 +584,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
|
||||
const { model: modelId, provider: providerName } = await getDefaultModelAndProvider();
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const model = createProvider(providerConfig).languageModel(modelId);
|
||||
const model = createLanguageModel(providerConfig, modelId);
|
||||
|
||||
const userPrompt = prompt || 'Convert this file to well-structured markdown.';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ChatActivity } from "./chat-activity.js";
|
||||
|
||||
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
|
||||
|
||||
describe("ChatActivity", () => {
|
||||
it("resolves waiters immediately when idle", async () => {
|
||||
const activity = new ChatActivity();
|
||||
await activity.waitUntilIdle();
|
||||
expect(activity.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks waiters until every active chat exits", async () => {
|
||||
const activity = new ChatActivity();
|
||||
activity.enter();
|
||||
activity.enter();
|
||||
|
||||
let released = false;
|
||||
const waiting = activity.waitUntilIdle().then(() => {
|
||||
released = true;
|
||||
});
|
||||
|
||||
activity.exit();
|
||||
await tick();
|
||||
expect(released).toBe(false);
|
||||
|
||||
activity.exit();
|
||||
await waiting;
|
||||
expect(released).toBe(true);
|
||||
});
|
||||
|
||||
it("wakes all pending waiters at once", async () => {
|
||||
const activity = new ChatActivity();
|
||||
activity.enter();
|
||||
const results: number[] = [];
|
||||
const a = activity.waitUntilIdle().then(() => results.push(1));
|
||||
const b = activity.waitUntilIdle().then(() => results.push(2));
|
||||
activity.exit();
|
||||
await Promise.all([a, b]);
|
||||
expect(results.sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("tolerates unbalanced exits", () => {
|
||||
const activity = new ChatActivity();
|
||||
activity.exit();
|
||||
expect(activity.activeCount).toBe(0);
|
||||
activity.enter();
|
||||
expect(activity.activeCount).toBe(1);
|
||||
});
|
||||
});
|
||||
39
apps/x/packages/core/src/application/lib/chat-activity.ts
Normal file
39
apps/x/packages/core/src/application/lib/chat-activity.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Tracks whether any user-facing chat turn is currently being processed.
|
||||
// Both chat runtimes mark their turns here (sessions/sessions.ts for the
|
||||
// turns runtime, agents/runtime.ts trigger() for legacy runs). Background
|
||||
// agent invocations consult it via startWhenPossible/runWhenPossible in
|
||||
// agents/headless-app.ts when the user enabled "Defer background tasks
|
||||
// while a chat is running" — useful on local models, where a background run
|
||||
// competes with the chat for the same hardware.
|
||||
export class ChatActivity {
|
||||
private active = 0;
|
||||
private waiters: Array<() => void> = [];
|
||||
|
||||
enter(): void {
|
||||
this.active++;
|
||||
}
|
||||
|
||||
exit(): void {
|
||||
this.active = Math.max(0, this.active - 1);
|
||||
if (this.active === 0) {
|
||||
const waiters = this.waiters.splice(0);
|
||||
for (const waiter of waiters) {
|
||||
waiter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
/** Resolves immediately when no chat turn is running. */
|
||||
waitUntilIdle(): Promise<void> {
|
||||
if (this.active === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => this.waiters.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
export const chatActivity = new ChatActivity();
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import type { EventConsumer, EventConsumerTarget } from '../events/consumer.js';
|
||||
import { routeBatch } from '../events/routing.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { createLanguageModel } from '../models/models.js';
|
||||
import {
|
||||
getDefaultModelAndProvider,
|
||||
getBackgroundTaskAgentModel,
|
||||
resolveProviderConfig,
|
||||
} from '../models/defaults.js';
|
||||
|
|
@ -10,11 +9,10 @@ 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: createProvider(config).languageModel(modelId),
|
||||
model: createLanguageModel(config, modelId),
|
||||
modelId,
|
||||
providerName: provider,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { BackgroundTask, BackgroundTaskTriggerType } from '@x/shared/dist/b
|
|||
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
|
||||
import { fetchTask, patchTask, prependRunId } from './fileops.js';
|
||||
import { getBackgroundTaskAgentModel } from '../models/defaults.js';
|
||||
import { startHeadlessAgent } from '../agents/headless-app.js';
|
||||
import { startHeadlessAgent, startWhenPossible } from '../agents/headless-app.js';
|
||||
import { buildTriggerBlock } from '../agents/build-trigger-block.js';
|
||||
import { backgroundTaskBus } from './bus.js';
|
||||
import { withUseCase } from '../analytics/use_case.js';
|
||||
|
|
@ -137,18 +137,29 @@ 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);
|
||||
// Manual runs are user-requested (the Run button, or the copilot's
|
||||
// run-background-task-agent tool mid-chat) and must NOT wait for
|
||||
// chat-idle: the requesting chat turn holds the chat-activity lock,
|
||||
// so deferring here would deadlock the turn. Only autonomous
|
||||
// triggers (cron/window/event) defer.
|
||||
const start = trigger === 'manual' ? startHeadlessAgent : startWhenPossible;
|
||||
// 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
|
||||
// through the turn's async execution chain.
|
||||
const handle = await withUseCase(
|
||||
{ useCase: 'background_task_agent', subUseCase: trigger },
|
||||
() => startHeadlessAgent({
|
||||
() => start({
|
||||
agentId: 'background-task-agent',
|
||||
message: buildMessage(slug, task, trigger, context, codeProject),
|
||||
model,
|
||||
...(task.provider ? { provider: task.provider } : {}),
|
||||
...(provider ? { provider } : {}),
|
||||
throwOnError: true,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { generateObject } from 'ai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { events, PrefixLogger } from '@x/shared';
|
||||
import { generateObjectSafe } from '../models/structured.js';
|
||||
import type { RowboatEvent } from '@x/shared/dist/events.js';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
import { withUseCase, type UseCase } from '../analytics/use_case.js';
|
||||
|
|
@ -89,11 +89,12 @@ export async function routeBatch(
|
|||
for (let i = 0; i < targets.length; i += BATCH_SIZE) {
|
||||
const batch = targets.slice(i, i + BATCH_SIZE);
|
||||
try {
|
||||
const result = await withUseCase({ useCase: opts.useCase, subUseCase: 'routing' }, () => generateObject({
|
||||
const result = await withUseCase({ useCase: opts.useCase, subUseCase: 'routing' }, () => generateObjectSafe({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: buildPrompt(event, batch, opts.entityPlural),
|
||||
schema: events.Pass1OutputSchema,
|
||||
retry: true,
|
||||
}));
|
||||
captureLlmUsage({
|
||||
useCase: opts.useCase,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { google } from 'googleapis';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { runHeadlessAgent } from '../agents/headless-app.js';
|
||||
import { runWhenPossible } from '../agents/headless-app.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { getErrorDetails } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
|
|
@ -281,10 +281,10 @@ async function processAgentNotes(): Promise<void> {
|
|||
const timestamp = new Date().toISOString();
|
||||
const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`;
|
||||
|
||||
await runHeadlessAgent({
|
||||
await runWhenPossible({
|
||||
agentId: AGENT_ID,
|
||||
message,
|
||||
model: await getKgModel(),
|
||||
...(await getKgModel()),
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js';
|
||||
import { runWhenPossible, toolInputPaths } from '../agents/headless-app.js';
|
||||
import { getErrorDetails } from '../agents/utils.js';
|
||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||
import {
|
||||
|
|
@ -385,10 +385,10 @@ async function createNotesFromBatch(
|
|||
message += `(3) No placeholder text ("Unknown"/"-") and no links between entities that didn't co-occur in one source file.\n`;
|
||||
}
|
||||
|
||||
const { turnId, state } = await runHeadlessAgent({
|
||||
const { turnId, state } = await runWhenPossible({
|
||||
agentId: NOTE_CREATION_AGENT,
|
||||
message,
|
||||
model: await getKgModel(),
|
||||
...(await getKgModel()),
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
|
|
@ -943,10 +943,10 @@ export async function curateNotes(): Promise<void> {
|
|||
message += `Curate the following knowledge note per your instructions. Rewrite it in place with a single file-writeText to the SAME path.\n\n`;
|
||||
message += `**Note path:** ${relPath}\n\n`;
|
||||
message += `**Current content:**\n\n${content}\n`;
|
||||
await runHeadlessAgent({
|
||||
await runWhenPossible({
|
||||
agentId: CURATION_AGENT,
|
||||
message,
|
||||
model: await getKgModel(),
|
||||
...(await getKgModel()),
|
||||
throwOnError: true,
|
||||
});
|
||||
curated++;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import { generateObject } from 'ai';
|
||||
import { google } from 'googleapis';
|
||||
import type { OAuth2Client } from 'google-auth-library';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { createLanguageModel } from '../models/models.js';
|
||||
import { generateObjectSafe } from '../models/structured.js';
|
||||
import {
|
||||
getDefaultModelAndProvider,
|
||||
getKgModel,
|
||||
resolveProviderConfig,
|
||||
} from '../models/defaults.js';
|
||||
|
|
@ -246,10 +245,9 @@ 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 = createProvider(config).languageModel(modelId);
|
||||
const model = createLanguageModel(config, modelId);
|
||||
|
||||
let systemPrompt = options.skipDraft
|
||||
? `${SYSTEM_PROMPT}\n\n# Skip the draft\n\nThe user already has their own draft in progress for this thread — DO NOT generate a draftResponse. Always omit the draftResponse field.`
|
||||
|
|
@ -262,11 +260,12 @@ export async function classifyThread(
|
|||
systemPrompt = `${systemPrompt}\n\n${feedback}`;
|
||||
}
|
||||
|
||||
const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'email_classifier' }, () => generateObject({
|
||||
const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'email_classifier' }, () => generateObjectSafe({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: buildPrompt(snapshot, userEmail, styleGuide, calendar),
|
||||
schema: ClassificationSchema,
|
||||
retry: true,
|
||||
}));
|
||||
|
||||
captureLlmUsage({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import path from 'path';
|
|||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { runHeadlessAgent } from '../agents/headless-app.js';
|
||||
import { runWhenPossible } from '../agents/headless-app.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import container from '../di/container.js';
|
||||
import type { IModelConfigRepo } from '../models/repo.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { createLanguageModel } from '../models/models.js';
|
||||
import { inlineTask } from '@x/shared';
|
||||
import { captureLlmUsage } from '../analytics/usage.js';
|
||||
import { withUseCase } from '../analytics/use_case.js';
|
||||
|
|
@ -480,10 +480,10 @@ async function processInlineTasks(): Promise<void> {
|
|||
'```',
|
||||
].join('\n');
|
||||
|
||||
const { summary: result } = await runHeadlessAgent({
|
||||
const { summary: result } = await runWhenPossible({
|
||||
agentId: INLINE_TASK_AGENT,
|
||||
message,
|
||||
model: await getKgModel(),
|
||||
...(await getKgModel()),
|
||||
});
|
||||
if (result) {
|
||||
if (task.targetId) {
|
||||
|
|
@ -559,10 +559,10 @@ export async function processRowboatInstruction(
|
|||
'```',
|
||||
].join('\n');
|
||||
|
||||
const { summary: rawResponse } = await runHeadlessAgent({
|
||||
const { summary: rawResponse } = await runWhenPossible({
|
||||
agentId: INLINE_TASK_AGENT,
|
||||
message,
|
||||
model: await getKgModel(),
|
||||
...(await getKgModel()),
|
||||
});
|
||||
if (!rawResponse) {
|
||||
return { instruction, schedule: null, scheduleLabel: null, response: null };
|
||||
|
|
@ -613,8 +613,7 @@ export async function processRowboatInstruction(
|
|||
export async function classifySchedule(instruction: string): Promise<InlineTaskSchedule | null> {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
const provider = createProvider(config.provider);
|
||||
const model = provider.languageModel(config.model);
|
||||
const model = createLanguageModel(config.provider, config.model);
|
||||
|
||||
const now = new Date();
|
||||
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js';
|
||||
import { runWhenPossible, toolInputPaths } from '../agents/headless-app.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { getErrorDetails } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
|
|
@ -84,10 +84,10 @@ async function labelEmailBatch(
|
|||
message += `\n\n---\n\n`;
|
||||
}
|
||||
|
||||
const { turnId, state } = await runHeadlessAgent({
|
||||
const { turnId, state } = await runWhenPossible({
|
||||
agentId: LABELING_AGENT,
|
||||
message,
|
||||
model: await getKgModel(),
|
||||
...(await getKgModel()),
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,15 +3,14 @@ import { fetchLiveNote } from './fileops.js';
|
|||
import { runLiveNoteAgent } from './runner.js';
|
||||
import type { EventConsumer, EventConsumerTarget } from '../../events/consumer.js';
|
||||
import { routeBatch } from '../../events/routing.js';
|
||||
import { createProvider } from '../../models/models.js';
|
||||
import { getDefaultModelAndProvider, getLiveNoteAgentModel, resolveProviderConfig } from '../../models/defaults.js';
|
||||
import { createLanguageModel } from '../../models/models.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: createProvider(config).languageModel(modelId),
|
||||
model: createLanguageModel(config, modelId),
|
||||
modelId,
|
||||
providerName: provider,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { LiveNote, LiveNoteTriggerType } from '@x/shared/dist/live-note.js';
|
||||
import { fetchLiveNote, patchLiveNote, readNoteBody } from './fileops.js';
|
||||
import { getLiveNoteAgentModel } from '../../models/defaults.js';
|
||||
import { startHeadlessAgent } from '../../agents/headless-app.js';
|
||||
import { startHeadlessAgent, startWhenPossible } from '../../agents/headless-app.js';
|
||||
import { withUseCase } from '../../analytics/use_case.js';
|
||||
import { buildTriggerBlock } from '../../agents/build-trigger-block.js';
|
||||
import { liveNoteBus } from './bus.js';
|
||||
|
|
@ -108,17 +108,28 @@ 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);
|
||||
// Manual runs are user-requested (the Run button, or the copilot's
|
||||
// run-live-note-agent tool mid-chat) and must NOT wait for chat-idle:
|
||||
// the requesting chat turn holds the chat-activity lock, so deferring
|
||||
// here would deadlock the turn. Only autonomous triggers
|
||||
// (cron/window/event) defer.
|
||||
const start = trigger === 'manual' ? startHeadlessAgent : startWhenPossible;
|
||||
// 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.
|
||||
const handle = await withUseCase(
|
||||
{ useCase: 'live_note_agent', subUseCase: trigger },
|
||||
() => startHeadlessAgent({
|
||||
() => start({
|
||||
agentId: 'live-note-agent',
|
||||
message: buildMessage(filePath, live, trigger, context),
|
||||
model,
|
||||
...(live.provider ? { provider: live.provider } : {}),
|
||||
...(provider ? { provider } : {}),
|
||||
throwOnError: true,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import fs from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import { generateText } from 'ai';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { createLanguageModel } from '../models/models.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,10 +175,9 @@ 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 = createProvider(providerConfig).languageModel(modelId);
|
||||
const model = createLanguageModel(providerConfig, modelId);
|
||||
|
||||
const result = await withUseCase({ useCase: 'meeting_prep' }, () => generateText({
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { generateText } from 'ai';
|
||||
import { createProvider } from '../models/models.js';
|
||||
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
|
||||
import { createLanguageModel } from '../models/models.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,10 +137,9 @@ 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 = createProvider(providerConfig).languageModel(modelId);
|
||||
const model = createLanguageModel(providerConfig, modelId);
|
||||
|
||||
// If a specific calendar event was linked, use it directly.
|
||||
// Otherwise fall back to scanning events within ±3 hours.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js';
|
||||
import { runWhenPossible, toolInputPaths } from '../agents/headless-app.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import { getErrorDetails } from '../agents/utils.js';
|
||||
import { serviceLogger } from '../services/service_logger.js';
|
||||
|
|
@ -97,10 +97,10 @@ async function tagNoteBatch(
|
|||
message += `\n\n---\n\n`;
|
||||
}
|
||||
|
||||
const { turnId, state } = await runHeadlessAgent({
|
||||
const { turnId, state } = await runWhenPossible({
|
||||
agentId: NOTE_TAGGING_AGENT,
|
||||
message,
|
||||
model: await getKgModel(),
|
||||
...(await getKgModel()),
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,20 +15,55 @@ 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Defer background tasks while a chat is running" (settings checkbox,
|
||||
* models.json `deferBackgroundTasks`). Read at each background invocation so
|
||||
* toggling takes effect immediately.
|
||||
*/
|
||||
export async function shouldDeferBackgroundTasks(): Promise<boolean> {
|
||||
const cfg = await readConfig();
|
||||
return cfg?.deferBackgroundTasks === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider name (as stored on a run, an agent, or returned by
|
||||
* getDefaultModelAndProvider) into the full LlmProvider config that
|
||||
|
|
@ -52,6 +87,8 @@ export async function resolveProviderConfig(name: string): Promise<z.infer<typeo
|
|||
apiKey: entry.apiKey,
|
||||
baseURL: entry.baseURL,
|
||||
headers: entry.headers,
|
||||
contextLength: entry.contextLength,
|
||||
reasoningEffort: entry.reasoningEffort,
|
||||
});
|
||||
}
|
||||
if (cfg.provider.flavor === name) {
|
||||
|
|
@ -60,48 +97,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,6 +159,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();
|
||||
}
|
||||
|
|
|
|||
81
apps/x/packages/core/src/models/local.test.ts
Normal file
81
apps/x/packages/core/src/models/local.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeOllamaThinkFetch, resolveThinkValue } from "./local.js";
|
||||
|
||||
describe("resolveThinkValue", () => {
|
||||
it("passes effort levels straight through for gpt-oss variants", () => {
|
||||
expect(resolveThinkValue("gpt-oss:20b", "low", true)).toBe("low");
|
||||
expect(resolveThinkValue("gpt-oss:120b", "high", true)).toBe("high");
|
||||
// gpt-oss can't disable thinking, so levels apply even if the
|
||||
// capability probe failed.
|
||||
expect(resolveThinkValue("gpt-oss:latest", "medium", false)).toBe("medium");
|
||||
});
|
||||
|
||||
it("maps effort to a boolean toggle for other thinking models", () => {
|
||||
expect(resolveThinkValue("qwen3.5:27b", "low", true)).toBe(false);
|
||||
expect(resolveThinkValue("qwen3.5:27b", "medium", true)).toBeUndefined();
|
||||
expect(resolveThinkValue("deepseek-r1:8b", "high", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("strips think for models without the thinking capability", () => {
|
||||
expect(resolveThinkValue("llama3.2:3b", "low", false)).toBeUndefined();
|
||||
expect(resolveThinkValue("llama3.2:3b", "high", false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeOllamaThinkFetch", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubFetch(capabilities: string[]) {
|
||||
const calls: Array<{ url: string; body: unknown }> = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, body: init?.body ? JSON.parse(init.body as string) : undefined });
|
||||
if (url.endsWith("/api/show")) {
|
||||
return new Response(JSON.stringify({ capabilities }), { status: 200 });
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}));
|
||||
return calls;
|
||||
}
|
||||
|
||||
it("rewrites think on /api/chat for gpt-oss", async () => {
|
||||
const calls = stubFetch(["completion", "tools", "thinking"]);
|
||||
const wrapped = makeOllamaThinkFetch("low");
|
||||
await wrapped("http://localhost:11434/api/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "gpt-oss:20b", think: false, messages: [] }),
|
||||
});
|
||||
const chat = calls.find((c) => c.url.endsWith("/api/chat"));
|
||||
expect((chat?.body as { think?: unknown }).think).toBe("low");
|
||||
});
|
||||
|
||||
it("strips think for non-thinking models", async () => {
|
||||
const calls = stubFetch(["completion", "tools"]);
|
||||
const wrapped = makeOllamaThinkFetch("high");
|
||||
await wrapped("http://localhost:11434/api/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "llama3.2:3b", think: false, messages: [] }),
|
||||
});
|
||||
const chat = calls.find((c) => c.url.endsWith("/api/chat"));
|
||||
expect(chat?.body as Record<string, unknown>).not.toHaveProperty("think");
|
||||
});
|
||||
|
||||
it("probes /api/show once per model and leaves other endpoints untouched", async () => {
|
||||
const calls = stubFetch(["completion", "thinking"]);
|
||||
const wrapped = makeOllamaThinkFetch("high");
|
||||
const chatBody = JSON.stringify({ model: "qwen3.5:27b", think: false, messages: [] });
|
||||
await wrapped("http://localhost:11434/api/chat", { method: "POST", body: chatBody });
|
||||
await wrapped("http://localhost:11434/api/chat", { method: "POST", body: chatBody });
|
||||
await wrapped("http://localhost:11434/api/tags", { method: "GET" });
|
||||
expect(calls.filter((c) => c.url.endsWith("/api/show")).length).toBe(1);
|
||||
const chats = calls.filter((c) => c.url.endsWith("/api/chat"));
|
||||
expect(chats).toHaveLength(2);
|
||||
for (const chat of chats) {
|
||||
expect((chat.body as { think?: unknown }).think).toBe(true);
|
||||
}
|
||||
// Non-chat request passed through with no body rewrite.
|
||||
expect(calls.some((c) => c.url.endsWith("/api/tags"))).toBe(true);
|
||||
});
|
||||
});
|
||||
140
apps/x/packages/core/src/models/local.ts
Normal file
140
apps/x/packages/core/src/models/local.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { wrapLanguageModel, type LanguageModel } from "ai";
|
||||
import type { z } from "zod";
|
||||
import type { LlmProvider } from "@x/shared/dist/models.js";
|
||||
|
||||
// Ollama's server-side default context window (~4k tokens) is far below what
|
||||
// Rowboat's agents need (the copilot's system prompt + tool schemas alone are
|
||||
// ~15-20k tokens) and Ollama silently truncates the prompt from the top when
|
||||
// it overflows — the model loses its own instructions. We therefore always
|
||||
// request an explicit window for Ollama models. Overridable per provider via
|
||||
// `contextLength` in models.json.
|
||||
export const DEFAULT_OLLAMA_CONTEXT_LENGTH = 32768;
|
||||
|
||||
export type ReasoningEffort = "low" | "medium" | "high";
|
||||
|
||||
// Local models default to snappy: gpt-oss at medium effort spends ~3x the
|
||||
// tokens of low on the same answer, and the AI SDK Ollama provider can't
|
||||
// express effort at all (its `think` option is boolean-only and hardcoded to
|
||||
// false, which thinking models like gpt-oss simply ignore).
|
||||
export const DEFAULT_OLLAMA_REASONING_EFFORT: ReasoningEffort = "low";
|
||||
|
||||
/**
|
||||
* Wrap a language model so every call requests an explicit context window
|
||||
* from Ollama (merged under the caller's providerOptions — an explicit
|
||||
* caller value wins). Non-Ollama providers pass through untouched.
|
||||
*/
|
||||
export function applyLocalModelSettings(
|
||||
model: LanguageModel,
|
||||
providerConfig: z.infer<typeof LlmProvider>,
|
||||
): LanguageModel {
|
||||
if (typeof model === "string" || providerConfig.flavor !== "ollama") {
|
||||
// Bare model-id strings resolve through the global registry; local
|
||||
// providers never take this path.
|
||||
return model;
|
||||
}
|
||||
const numCtx = providerConfig.contextLength ?? DEFAULT_OLLAMA_CONTEXT_LENGTH;
|
||||
return wrapLanguageModel({
|
||||
model,
|
||||
middleware: {
|
||||
transformParams: async ({ params }) => {
|
||||
const providerOptions = (params.providerOptions ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const ollama = (providerOptions.ollama ?? {}) as Record<string, unknown>;
|
||||
const options = (ollama.options ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...params,
|
||||
providerOptions: {
|
||||
...providerOptions,
|
||||
ollama: {
|
||||
...ollama,
|
||||
options: { num_ctx: numCtx, ...options },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a configured effort to the Ollama `think` value for a given model.
|
||||
* - gpt-oss accepts effort levels directly ("low" | "medium" | "high").
|
||||
* - Other thinking models (qwen3, deepseek-r1, …) only toggle: low turns
|
||||
* thinking off, high forces it on, medium leaves the model default.
|
||||
* - Models without the thinking capability must not receive `think` at all.
|
||||
* Returns undefined when `think` should be stripped from the request.
|
||||
*/
|
||||
export function resolveThinkValue(
|
||||
modelName: string,
|
||||
effort: ReasoningEffort,
|
||||
supportsThinking: boolean,
|
||||
): boolean | string | undefined {
|
||||
if (/gpt-oss/i.test(modelName)) {
|
||||
return effort;
|
||||
}
|
||||
if (!supportsThinking) {
|
||||
return undefined;
|
||||
}
|
||||
switch (effort) {
|
||||
case "low":
|
||||
return false;
|
||||
case "medium":
|
||||
return undefined;
|
||||
case "high":
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// The ollama-ai-provider-v2 request builder always writes `think: false`
|
||||
// (its providerOptions schema is boolean-only), so effort can only be set by
|
||||
// rewriting the wire request. This wraps fetch for createOllama: /api/chat
|
||||
// bodies get `think` set per resolveThinkValue; every other request passes
|
||||
// through untouched. Thinking capability is probed once per model via
|
||||
// /api/show and cached for the process lifetime.
|
||||
export function makeOllamaThinkFetch(
|
||||
effort: ReasoningEffort,
|
||||
): typeof fetch {
|
||||
const thinkingSupport = new Map<string, Promise<boolean>>();
|
||||
|
||||
const supportsThinking = (chatUrl: string, model: string): Promise<boolean> => {
|
||||
const showUrl = chatUrl.replace(/\/chat(\?.*)?$/, "/show");
|
||||
const key = `${showUrl}|${model}`;
|
||||
let cached = thinkingSupport.get(key);
|
||||
if (!cached) {
|
||||
cached = fetch(showUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json() as { capabilities?: string[] };
|
||||
return Array.isArray(data.capabilities) && data.capabilities.includes("thinking");
|
||||
})
|
||||
.catch(() => false);
|
||||
thinkingSupport.set(key, cached);
|
||||
}
|
||||
return cached;
|
||||
};
|
||||
|
||||
return async (input, init) => {
|
||||
try {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const isChat = /\/api\/chat(\?.*)?$/.test(url);
|
||||
const body = init?.body;
|
||||
if (isChat && typeof body === "string") {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
const model = typeof parsed.model === "string" ? parsed.model : "";
|
||||
const think = resolveThinkValue(model, effort, await supportsThinking(url, model));
|
||||
if (think === undefined) {
|
||||
delete parsed.think;
|
||||
} else {
|
||||
parsed.think = think;
|
||||
}
|
||||
return fetch(input, { ...init, body: JSON.stringify(parsed) });
|
||||
}
|
||||
} catch {
|
||||
// Malformed body or URL — send the original request unchanged.
|
||||
}
|
||||
return fetch(input, init);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { ProviderV2 } from "@ai-sdk/provider";
|
||||
import { createGateway, generateText } from "ai";
|
||||
import { createGateway, generateText, type LanguageModel } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
|
|
@ -12,6 +12,12 @@ import { getGatewayProvider } from "./gateway.js";
|
|||
import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js";
|
||||
import { getChatModelIds } from "./models-dev.js";
|
||||
import { withUseCase } from "../analytics/use_case.js";
|
||||
import {
|
||||
applyLocalModelSettings,
|
||||
makeOllamaThinkFetch,
|
||||
DEFAULT_OLLAMA_CONTEXT_LENGTH,
|
||||
DEFAULT_OLLAMA_REASONING_EFFORT,
|
||||
} from "./local.js";
|
||||
|
||||
export const Provider = LlmProvider;
|
||||
export const ModelConfig = LlmModelConfig;
|
||||
|
|
@ -52,6 +58,12 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
|
|||
return createOllama({
|
||||
baseURL: ollamaURL,
|
||||
headers,
|
||||
// Rewrites `think` on the wire: the provider itself can only
|
||||
// send think:false, which thinking models ignore — leaving
|
||||
// e.g. gpt-oss at medium effort. See makeOllamaThinkFetch.
|
||||
fetch: makeOllamaThinkFetch(
|
||||
config.reasoningEffort ?? DEFAULT_OLLAMA_REASONING_EFFORT,
|
||||
),
|
||||
});
|
||||
}
|
||||
case "openai-compatible":
|
||||
|
|
@ -74,24 +86,146 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place model instances are created. Applies local-runtime settings
|
||||
* (explicit Ollama context window) on top of the raw provider model.
|
||||
*/
|
||||
export function createLanguageModel(
|
||||
providerConfig: z.infer<typeof Provider>,
|
||||
modelId: string,
|
||||
): LanguageModel {
|
||||
const model = createProvider(providerConfig).languageModel(modelId);
|
||||
return applyLocalModelSettings(model, providerConfig);
|
||||
}
|
||||
|
||||
export interface ModelCapabilities {
|
||||
/** undefined = could not be determined (endpoint missing, non-local provider). */
|
||||
supportsTools?: boolean;
|
||||
maxContextLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort capability probe for local runtimes. Ollama reports a
|
||||
* `capabilities` list and the model's trained context window via /api/show;
|
||||
* LM Studio exposes the same through its /api/v0/models REST endpoint.
|
||||
* Failures are swallowed — an unknown capability is not an error.
|
||||
*/
|
||||
export async function probeModelCapabilities(
|
||||
providerConfig: z.infer<typeof Provider>,
|
||||
model: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<ModelCapabilities> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
if (providerConfig.flavor === "ollama") {
|
||||
const base = (providerConfig.baseURL ?? "http://localhost:11434")
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/api$/, "");
|
||||
const res = await fetch(`${base}/api/show`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(providerConfig.headers ?? {}) },
|
||||
body: JSON.stringify({ model }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
const data = await res.json() as {
|
||||
capabilities?: string[];
|
||||
model_info?: Record<string, unknown>;
|
||||
};
|
||||
const result: ModelCapabilities = {};
|
||||
if (Array.isArray(data.capabilities)) {
|
||||
result.supportsTools = data.capabilities.includes("tools");
|
||||
}
|
||||
for (const [key, value] of Object.entries(data.model_info ?? {})) {
|
||||
if (key.endsWith(".context_length") && typeof value === "number") {
|
||||
result.maxContextLength = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (providerConfig.flavor === "openai-compatible") {
|
||||
// LM Studio's enhanced REST API lives at /api/v0 on the same
|
||||
// origin as the OpenAI-compatible /v1 endpoint. Non-LM Studio
|
||||
// endpoints just 404 here, which reports as "unknown".
|
||||
const origin = new URL(providerConfig.baseURL ?? "").origin;
|
||||
const res = await fetch(`${origin}/api/v0/models`, {
|
||||
headers: providerConfig.headers ?? {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
const data = await res.json() as { data?: Array<Record<string, unknown>> };
|
||||
const entry = (data.data ?? []).find((m) => m.id === model);
|
||||
if (!entry) return {};
|
||||
const result: ModelCapabilities = {};
|
||||
if (Array.isArray(entry.capabilities)) {
|
||||
result.supportsTools = (entry.capabilities as string[]).includes("tool_use");
|
||||
}
|
||||
const max = entry.loaded_context_length ?? entry.max_context_length;
|
||||
if (typeof max === "number") {
|
||||
result.maxContextLength = max;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function capabilityWarnings(
|
||||
providerConfig: z.infer<typeof Provider>,
|
||||
model: string,
|
||||
capabilities: ModelCapabilities,
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
if (capabilities.supportsTools === false) {
|
||||
warnings.push(
|
||||
`${model} does not support tool calling. Rowboat's assistant and background agents rely on tools; pick a tool-capable model (e.g. qwen3, gpt-oss, llama3.3).`,
|
||||
);
|
||||
}
|
||||
const configured = providerConfig.contextLength
|
||||
?? (providerConfig.flavor === "ollama" ? DEFAULT_OLLAMA_CONTEXT_LENGTH : undefined);
|
||||
if (capabilities.maxContextLength !== undefined) {
|
||||
if (capabilities.maxContextLength < 16384) {
|
||||
warnings.push(
|
||||
`${model} has a ${capabilities.maxContextLength}-token context window. Rowboat's assistant needs ~16k+ tokens; expect truncated or confused responses.`,
|
||||
);
|
||||
} else if (configured !== undefined && capabilities.maxContextLength < configured) {
|
||||
warnings.push(
|
||||
`${model} supports at most ${capabilities.maxContextLength} context tokens, below the configured ${configured}. Set "contextLength" for this provider in models.json to ${capabilities.maxContextLength} or less.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export async function testModelConnection(
|
||||
providerConfig: z.infer<typeof Provider>,
|
||||
model: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
): Promise<{ success: boolean; error?: string; warnings?: string[]; capabilities?: ModelCapabilities }> {
|
||||
const isLocal = providerConfig.flavor === "ollama" || providerConfig.flavor === "openai-compatible";
|
||||
const effectiveTimeout = timeoutMs ?? (isLocal ? 60000 : 8000);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), effectiveTimeout);
|
||||
try {
|
||||
const provider = createProvider(providerConfig);
|
||||
const languageModel = provider.languageModel(model);
|
||||
const languageModel = createLanguageModel(providerConfig, model);
|
||||
await generateText({
|
||||
model: languageModel,
|
||||
prompt: "ping",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
return { success: true };
|
||||
const capabilities = await probeModelCapabilities(providerConfig, model);
|
||||
const warnings = capabilityWarnings(providerConfig, model, capabilities);
|
||||
return {
|
||||
success: true,
|
||||
...(warnings.length > 0 ? { warnings } : {}),
|
||||
capabilities,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Connection test failed";
|
||||
return { success: false, error: message };
|
||||
|
|
@ -203,7 +337,7 @@ export async function generateOneShot(opts: GenerateTextOptions): Promise<Genera
|
|||
const modelId = opts.model || def.model;
|
||||
const providerName = opts.provider || def.provider;
|
||||
const providerConfig = await resolveProviderConfig(providerName);
|
||||
const languageModel = createProvider(providerConfig).languageModel(modelId);
|
||||
const languageModel = createLanguageModel(providerConfig, modelId);
|
||||
const result = await withUseCase(
|
||||
{ useCase: "copilot_chat", subUseCase: "email_compose" },
|
||||
() => generateText({
|
||||
|
|
|
|||
|
|
@ -4,10 +4,25 @@ import fs from "fs/promises";
|
|||
import path from "path";
|
||||
import z from "zod";
|
||||
|
||||
export type ModelConfigPatch = {
|
||||
[K in
|
||||
| "defaultSelection"
|
||||
| "knowledgeGraphModel"
|
||||
| "meetingNotesModel"
|
||||
| "liveNoteAgentModel"
|
||||
| "autoPermissionDecisionModel"
|
||||
| "deferBackgroundTasks"]?: 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> = {
|
||||
|
|
@ -48,6 +63,13 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
apiKey: config.provider.apiKey,
|
||||
baseURL: config.provider.baseURL,
|
||||
headers: config.provider.headers,
|
||||
// Preserve hand-edited local-model tuning unless the caller sets it.
|
||||
...(config.provider.contextLength !== undefined
|
||||
? { contextLength: config.provider.contextLength }
|
||||
: {}),
|
||||
...(config.provider.reasoningEffort !== undefined
|
||||
? { reasoningEffort: config.provider.reasoningEffort }
|
||||
: {}),
|
||||
model: config.model,
|
||||
models: config.models,
|
||||
knowledgeGraphModel: config.knowledgeGraphModel,
|
||||
|
|
@ -56,7 +78,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", "deferBackgroundTasks"]
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
apps/x/packages/core/src/models/structured.test.ts
Normal file
87
apps/x/packages/core/src/models/structured.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { NoObjectGeneratedError } from "ai";
|
||||
import type { LanguageModel } from "ai";
|
||||
import { generateObjectSafe } from "./structured.js";
|
||||
|
||||
const Schema = z.object({ ids: z.array(z.string()) });
|
||||
|
||||
// A minimal LanguageModelV2 double whose doGenerate returns the given texts
|
||||
// in sequence. generateObject parses the text against the schema itself, so
|
||||
// malformed text surfaces as NoObjectGeneratedError — exactly the local-model
|
||||
// failure mode generateObjectSafe exists to absorb.
|
||||
function fakeModel(responses: string[]): LanguageModel {
|
||||
let call = 0;
|
||||
return {
|
||||
specificationVersion: "v2",
|
||||
provider: "fake",
|
||||
modelId: "fake-model",
|
||||
supportedUrls: {},
|
||||
doGenerate: async () => {
|
||||
const text = responses[Math.min(call++, responses.length - 1)];
|
||||
return {
|
||||
content: [{ type: "text" as const, text }],
|
||||
finishReason: "stop" as const,
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
warnings: [],
|
||||
};
|
||||
},
|
||||
doStream: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
} as unknown as LanguageModel;
|
||||
}
|
||||
|
||||
describe("generateObjectSafe", () => {
|
||||
it("passes through a clean structured response", async () => {
|
||||
const result = await generateObjectSafe({
|
||||
model: fakeModel(['{"ids":["a","b"]}']),
|
||||
prompt: "p",
|
||||
schema: Schema,
|
||||
});
|
||||
expect(result.object).toEqual({ ids: ["a", "b"] });
|
||||
expect(result.usage?.totalTokens).toBe(2);
|
||||
});
|
||||
|
||||
it("salvages JSON wrapped in prose and think blocks", async () => {
|
||||
const result = await generateObjectSafe({
|
||||
model: fakeModel([
|
||||
'<think>hmm {"ids":["x"]} maybe</think>Sure! Here you go:\n```json\n{"ids":["note-1","note-2"]}\n```\nLet me know!',
|
||||
]),
|
||||
prompt: "p",
|
||||
schema: Schema,
|
||||
});
|
||||
expect(result.object).toEqual({ ids: ["note-1", "note-2"] });
|
||||
});
|
||||
|
||||
it("retries once with a reinforced instruction when enabled", async () => {
|
||||
const result = await generateObjectSafe({
|
||||
model: fakeModel(["I cannot answer in JSON, sorry!", '{"ids":[]}']),
|
||||
prompt: "p",
|
||||
schema: Schema,
|
||||
retry: true,
|
||||
});
|
||||
expect(result.object).toEqual({ ids: [] });
|
||||
});
|
||||
|
||||
it("throws the original error when salvage and retry both fail", async () => {
|
||||
await expect(
|
||||
generateObjectSafe({
|
||||
model: fakeModel(["not json at all"]),
|
||||
prompt: "p",
|
||||
schema: Schema,
|
||||
retry: true,
|
||||
}),
|
||||
).rejects.toSatisfy((error: unknown) => NoObjectGeneratedError.isInstance(error));
|
||||
});
|
||||
|
||||
it("does not retry when retry is disabled", async () => {
|
||||
await expect(
|
||||
generateObjectSafe({
|
||||
model: fakeModel(["nope", '{"ids":[]}']),
|
||||
prompt: "p",
|
||||
schema: Schema,
|
||||
}),
|
||||
).rejects.toSatisfy((error: unknown) => NoObjectGeneratedError.isInstance(error));
|
||||
});
|
||||
});
|
||||
135
apps/x/packages/core/src/models/structured.ts
Normal file
135
apps/x/packages/core/src/models/structured.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import {
|
||||
generateObject,
|
||||
NoObjectGeneratedError,
|
||||
type LanguageModel,
|
||||
type LanguageModelUsage,
|
||||
} from "ai";
|
||||
import type { z } from "zod";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
|
||||
const log = new PrefixLogger("StructuredOutput");
|
||||
|
||||
const NO_JSON = Symbol("no-json");
|
||||
|
||||
export interface GenerateObjectSafeOptions<T> {
|
||||
model: LanguageModel;
|
||||
system?: string;
|
||||
prompt: string;
|
||||
schema: z.ZodType<T>;
|
||||
/**
|
||||
* Retry once with a reinforced JSON-only instruction when the first
|
||||
* attempt produces unparseable output. Local/small models miss strict
|
||||
* schema output far more often than frontier models, so callers that may
|
||||
* run on a local model should enable this.
|
||||
*/
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
export interface GenerateObjectSafeResult<T> {
|
||||
object: T;
|
||||
usage?: LanguageModelUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* generateObject with degradation paths for models that can't reliably emit
|
||||
* strict JSON: (1) salvage a schema-valid JSON value out of the raw response
|
||||
* text (small models wrap JSON in prose, fences, or <think> blocks), then
|
||||
* (2) optionally retry once with a reinforced instruction. Throws the
|
||||
* original error when nothing works, so callers' failure handling is
|
||||
* unchanged.
|
||||
*/
|
||||
export async function generateObjectSafe<T>(
|
||||
options: GenerateObjectSafeOptions<T>,
|
||||
): Promise<GenerateObjectSafeResult<T>> {
|
||||
try {
|
||||
const result = await generateObject({
|
||||
model: options.model,
|
||||
...(options.system ? { system: options.system } : {}),
|
||||
prompt: options.prompt,
|
||||
schema: options.schema,
|
||||
});
|
||||
return { object: result.object, usage: result.usage };
|
||||
} catch (error) {
|
||||
const salvaged = salvage(error, options.schema);
|
||||
if (salvaged) {
|
||||
log.log("salvaged schema-valid JSON from a malformed response");
|
||||
return salvaged;
|
||||
}
|
||||
if (!options.retry) {
|
||||
throw error;
|
||||
}
|
||||
log.log(
|
||||
`first attempt failed (${error instanceof Error ? error.message : String(error)}); retrying with reinforced JSON instruction`,
|
||||
);
|
||||
try {
|
||||
const system = [
|
||||
options.system ?? "",
|
||||
"Return ONLY a single valid JSON value that matches the requested schema. No prose, no markdown fences, no explanations.",
|
||||
].join("\n\n").trim();
|
||||
const result = await generateObject({
|
||||
model: options.model,
|
||||
system,
|
||||
prompt: options.prompt,
|
||||
schema: options.schema,
|
||||
});
|
||||
return { object: result.object, usage: result.usage };
|
||||
} catch (retryError) {
|
||||
const retrySalvaged = salvage(retryError, options.schema);
|
||||
if (retrySalvaged) {
|
||||
log.log("salvaged schema-valid JSON from the retry response");
|
||||
return retrySalvaged;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function salvage<T>(
|
||||
error: unknown,
|
||||
schema: z.ZodType<T>,
|
||||
): GenerateObjectSafeResult<T> | null {
|
||||
if (!NoObjectGeneratedError.isInstance(error) || typeof error.text !== "string") {
|
||||
return null;
|
||||
}
|
||||
const candidate = extractJson(error.text);
|
||||
if (candidate === NO_JSON) {
|
||||
return null;
|
||||
}
|
||||
const parsed = schema.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
return { object: parsed.data, usage: error.usage };
|
||||
}
|
||||
|
||||
// Pull a JSON value out of chatty model output: drop <think> blocks, prefer
|
||||
// fenced content, then fall back to the widest parseable {...}/[...] span.
|
||||
function extractJson(raw: string): unknown {
|
||||
let text = raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fence) {
|
||||
text = fence[1].trim();
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
// fall through to span scan
|
||||
}
|
||||
const starts = [text.indexOf("{"), text.indexOf("[")].filter((i) => i >= 0);
|
||||
if (starts.length === 0) {
|
||||
return NO_JSON;
|
||||
}
|
||||
const start = Math.min(...starts);
|
||||
for (let end = text.length; end > start; end--) {
|
||||
const tail = text[end - 1];
|
||||
if (tail !== "}" && tail !== "]") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text.slice(start, end));
|
||||
} catch {
|
||||
// keep shrinking
|
||||
}
|
||||
}
|
||||
return NO_JSON;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { WorkDir } from '../config/config.js';
|
||||
import { runHeadlessAgent } from '../agents/headless-app.js';
|
||||
import { runWhenPossible } from '../agents/headless-app.js';
|
||||
import { getKgModel } from '../models/defaults.js';
|
||||
import {
|
||||
loadConfig,
|
||||
|
|
@ -52,10 +52,10 @@ Process new items and use the user context above to identify yourself when draft
|
|||
// The agent file is expected to be in the agents directory with
|
||||
// the same name. Waits for the turn to settle (errors tolerated,
|
||||
// matching the old no-throwOnError wait).
|
||||
await runHeadlessAgent({
|
||||
await runWhenPossible({
|
||||
agentId: agentName,
|
||||
message,
|
||||
model: await getKgModel(),
|
||||
...(await getKgModel()),
|
||||
});
|
||||
|
||||
// Update last run time
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { generateObject, type ModelMessage } from "ai";
|
||||
import type { ModelMessage } from "ai";
|
||||
import z from "zod";
|
||||
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 { createProvider } from "../models/models.js";
|
||||
import { getAutoPermissionDecisionModel, resolveProviderConfig } from "../models/defaults.js";
|
||||
import { createLanguageModel } from "../models/models.js";
|
||||
import { generateObjectSafe } from "../models/structured.js";
|
||||
|
||||
const DecisionSchema = z.object({
|
||||
decisions: z.array(z.object({
|
||||
|
|
@ -80,10 +81,9 @@ 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 = createProvider(providerConfig).languageModel(modelId);
|
||||
const model = createLanguageModel(providerConfig, modelId);
|
||||
|
||||
const result = await withUseCase(
|
||||
{
|
||||
|
|
@ -91,11 +91,12 @@ export async function classifyToolPermissions(input: {
|
|||
subUseCase: "auto_permission_classifier",
|
||||
...(input.agentName ? { agentName: input.agentName } : {}),
|
||||
},
|
||||
() => generateObject({
|
||||
() => generateObjectSafe({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: buildPrompt(input),
|
||||
schema: DecisionSchema,
|
||||
retry: true,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
reduceTurn,
|
||||
} from "@x/shared/dist/turns.js";
|
||||
import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { chatActivity } from "../application/lib/chat-activity.js";
|
||||
import type {
|
||||
ITurnRuntime,
|
||||
Turn,
|
||||
|
|
@ -339,9 +340,17 @@ export class SessionsImpl implements ISessions {
|
|||
input?: TurnExternalInput,
|
||||
): TurnExecution {
|
||||
const controller = new AbortController();
|
||||
// A session turn is a user-facing chat: mark it active so background
|
||||
// agents can defer (see agents/headless-app.ts runWhenPossible).
|
||||
if (sessionId !== null) {
|
||||
chatActivity.enter();
|
||||
}
|
||||
const execution = this.turnRuntime.advanceTurn(turnId, input, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (sessionId !== null) {
|
||||
void execution.outcome.catch(() => undefined).finally(() => chatActivity.exit());
|
||||
}
|
||||
this.active.set(turnId, { sessionId, controller, execution });
|
||||
|
||||
void (async () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ 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 { applyLocalModelSettings } from "../../models/local.js";
|
||||
import type {
|
||||
IModelRegistry,
|
||||
LlmStreamEvent,
|
||||
|
|
@ -29,6 +30,10 @@ export type StreamTextInvoker = (options: {
|
|||
messages: ModelMessage[];
|
||||
tools: ToolSet;
|
||||
abortSignal: AbortSignal;
|
||||
temperature?: number;
|
||||
topP?: number;
|
||||
maxOutputTokens?: number;
|
||||
providerOptions?: Record<string, Record<string, JsonValue>>;
|
||||
}) => { fullStream: AsyncIterable<unknown> };
|
||||
|
||||
const defaultInvoker: StreamTextInvoker = (options) =>
|
||||
|
|
@ -61,7 +66,11 @@ export class RealModelRegistry implements IModelRegistry {
|
|||
): Promise<ResolvedModel> {
|
||||
const providerConfig = await this.resolveProvider(descriptor.provider);
|
||||
const provider = this.createProviderImpl(providerConfig);
|
||||
const model = provider.languageModel(descriptor.model);
|
||||
// Local settings (Ollama context window) are applied here.
|
||||
const model = applyLocalModelSettings(
|
||||
provider.languageModel(descriptor.model),
|
||||
providerConfig,
|
||||
);
|
||||
return {
|
||||
descriptor,
|
||||
// The structural -> wire conversion the app uses today: weaves
|
||||
|
|
@ -93,13 +102,17 @@ export class RealModelRegistry implements IModelRegistry {
|
|||
});
|
||||
}
|
||||
|
||||
const result = this.invoke({
|
||||
model,
|
||||
system: request.systemPrompt,
|
||||
messages: request.messages as ModelMessage[],
|
||||
tools,
|
||||
abortSignal: request.signal,
|
||||
});
|
||||
// Persisted per-call parameters (turn-runtime-design.md §8.3): only
|
||||
// the whitelisted generation knobs are forwarded to the provider.
|
||||
const params = request.parameters ?? {};
|
||||
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>> }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const parts: Array<z.infer<typeof AssistantContentPart>> = [];
|
||||
let textBuffer = "";
|
||||
|
|
@ -108,6 +121,15 @@ export class RealModelRegistry implements IModelRegistry {
|
|||
let usage: z.infer<typeof TurnUsage> = {};
|
||||
let providerMetadata: JsonValue | undefined;
|
||||
|
||||
const result = this.invoke({
|
||||
model,
|
||||
system: request.systemPrompt,
|
||||
messages: request.messages as ModelMessage[],
|
||||
tools,
|
||||
abortSignal: request.signal,
|
||||
...generationParams,
|
||||
});
|
||||
|
||||
for await (const raw of result.fullStream) {
|
||||
request.signal.throwIfAborted();
|
||||
const event = raw as {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -566,6 +566,13 @@ const ipcSchemas = {
|
|||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
// Capability caveats from the local-model probe (tool support, context
|
||||
// window) — the connection still succeeded.
|
||||
warnings: z.array(z.string()).optional(),
|
||||
capabilities: z.object({
|
||||
supportsTools: z.boolean().optional(),
|
||||
maxContextLength: z.number().optional(),
|
||||
}).optional(),
|
||||
}),
|
||||
},
|
||||
'models:listForProvider': {
|
||||
|
|
@ -605,6 +612,23 @@ 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(),
|
||||
deferBackgroundTasks: z.boolean().nullable().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'oauth:connect': {
|
||||
req: z.object({
|
||||
provider: z.string(),
|
||||
|
|
|
|||
|
|
@ -5,16 +5,49 @@ export const LlmProvider = z.object({
|
|||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
// Context window (in tokens) to request from local runtimes. Ollama defaults
|
||||
// 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(),
|
||||
});
|
||||
|
||||
// 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(),
|
||||
// When true, background agent runs (knowledge pipeline, live notes,
|
||||
// background tasks) wait until no chat turn is running before starting.
|
||||
// Surfaced as a settings checkbox; recommended for local models, where a
|
||||
// background run competes with the chat for the same hardware.
|
||||
deferBackgroundTasks: z.boolean().optional(),
|
||||
providers: z.record(z.string(), z.object({
|
||||
apiKey: z.string().optional(),
|
||||
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(),
|
||||
model: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
|
|
@ -22,10 +55,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(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue