make local models work well

This commit is contained in:
Arjun 2026-07-05 11:45:22 +05:30
parent 596edcd788
commit 799d7584b8
23 changed files with 938 additions and 131 deletions

View file

@ -528,7 +528,17 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
setDefaultProvider(provider)
setTestState({ status: "success" })
window.dispatchEvent(new Event('models-config-changed'))
toast.success("Model configuration saved")
// 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")

View file

@ -19,7 +19,7 @@ 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 { resolveProviderConfig } from "../models/defaults.js";
import { IAgentsRepo } from "./repo.js";
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
@ -1365,8 +1365,8 @@ export async function* streamAgent({
}
const modelId = state.runModel;
const providerConfig = await resolveProviderConfig(state.runProvider);
const provider = createProvider(providerConfig);
const model = provider.languageModel(modelId);
// Legacy runs are user-facing chats: interactive priority on local models.
const model = createLanguageModel(providerConfig, modelId, { priority: "interactive" });
logger.log(`using model: ${modelId} (provider: ${state.runProvider})`);
// Install use-case context for tool-internal LLM calls (e.g. parseFile)

View file

@ -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,8 @@ 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);
// Runs as a tool inside a chat turn more often than not.
const model = createLanguageModel(providerConfig, modelId, { priority: 'interactive' });
const userPrompt = prompt || 'Convert this file to well-structured markdown.';

View file

@ -1,6 +1,6 @@
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,
@ -14,7 +14,7 @@ async function resolveRoutingModel() {
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
return {
model: createProvider(config).languageModel(modelId),
model: createLanguageModel(config, modelId, { priority: 'classifier' }),
modelId,
providerName: provider,
};

View file

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

View file

@ -1,11 +1,11 @@
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,
@ -249,7 +249,7 @@ export async function classifyThread(
const modelId = await getKgModel();
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
const model = createProvider(config).languageModel(modelId);
const model = createLanguageModel(config, modelId, { priority: 'background' });
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 +262,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({

View file

@ -7,7 +7,7 @@ import { runHeadlessAgent } 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';
@ -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, { priority: 'classifier' });
const now = new Date();
const defaultEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);

View file

@ -3,7 +3,7 @@ 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 { createLanguageModel } from '../../models/models.js';
import { getDefaultModelAndProvider, getLiveNoteAgentModel, resolveProviderConfig } from '../../models/defaults.js';
async function resolveRoutingModel() {
@ -11,7 +11,7 @@ async function resolveRoutingModel() {
const { provider } = await getDefaultModelAndProvider();
const config = await resolveProviderConfig(provider);
return {
model: createProvider(config).languageModel(modelId),
model: createLanguageModel(config, modelId, { priority: 'classifier' }),
modelId,
providerName: provider,
};

View file

@ -2,7 +2,7 @@ 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 { createLanguageModel } from '../models/models.js';
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
import { captureLlmUsage } from '../analytics/usage.js';
import { withUseCase } from '../analytics/use_case.js';
@ -178,7 +178,7 @@ async function generateBrief(event: CalendarEvent, ctx: Awaited<ReturnType<typeo
const modelId = await getMeetingNotesModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const providerConfig = await resolveProviderConfig(providerName);
const model = createProvider(providerConfig).languageModel(modelId);
const model = createLanguageModel(providerConfig, modelId, { priority: 'background' });
const result = await withUseCase({ useCase: 'meeting_prep' }, () => generateText({
model,

View file

@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import { generateText } from 'ai';
import { createProvider } from '../models/models.js';
import { createLanguageModel } from '../models/models.js';
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
import { WorkDir } from '../config/config.js';
import { captureLlmUsage } from '../analytics/usage.js';
@ -140,7 +140,7 @@ export async function summarizeMeeting(transcript: string, meetingStartTime?: st
const modelId = await getMeetingNotesModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const providerConfig = await resolveProviderConfig(providerName);
const model = createProvider(providerConfig).languageModel(modelId);
const model = createLanguageModel(providerConfig, modelId, { priority: 'background' });
// If a specific calendar event was linked, use it directly.
// Otherwise fall back to scanning events within ±3 hours.

View file

@ -52,6 +52,7 @@ export async function resolveProviderConfig(name: string): Promise<z.infer<typeo
apiKey: entry.apiKey,
baseURL: entry.baseURL,
headers: entry.headers,
contextLength: entry.contextLength,
});
}
if (cfg.provider.flavor === name) {

View file

@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { LocalLlmScheduler, isLocalProvider } from "./local.js";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
describe("isLocalProvider", () => {
it("treats ollama as local", () => {
expect(isLocalProvider({ flavor: "ollama" })).toBe(true);
});
it("treats loopback openai-compatible endpoints as local", () => {
expect(isLocalProvider({ flavor: "openai-compatible", baseURL: "http://localhost:1234/v1" })).toBe(true);
expect(isLocalProvider({ flavor: "openai-compatible", baseURL: "http://127.0.0.1:8080/v1" })).toBe(true);
});
it("treats remote openai-compatible endpoints and cloud flavors as non-local", () => {
expect(isLocalProvider({ flavor: "openai-compatible", baseURL: "https://api.together.xyz/v1" })).toBe(false);
expect(isLocalProvider({ flavor: "openai" })).toBe(false);
expect(isLocalProvider({ flavor: "rowboat" })).toBe(false);
});
});
describe("LocalLlmScheduler", () => {
it("serves waiters by priority, FIFO within a priority", async () => {
const scheduler = new LocalLlmScheduler(1);
const order: string[] = [];
const first = deferred();
const running = scheduler.run("background", undefined, async () => {
await first.promise;
order.push("initial");
});
await tick();
const bg1 = scheduler.run("background", undefined, async () => {
order.push("bg1");
});
const bg2 = scheduler.run("background", undefined, async () => {
order.push("bg2");
});
const chat = scheduler.run("interactive", undefined, async () => {
order.push("chat");
});
const classifier = scheduler.run("classifier", undefined, async () => {
order.push("classifier");
});
await tick();
first.resolve();
await Promise.all([running, bg1, bg2, chat, classifier]);
expect(order).toEqual(["initial", "chat", "classifier", "bg1", "bg2"]);
});
it("releases the slot when a task throws", async () => {
const scheduler = new LocalLlmScheduler(1);
await expect(
scheduler.run("interactive", undefined, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
// Slot must be free again.
await scheduler.run("interactive", undefined, async () => undefined);
});
it("rejects queued waiters whose signal aborts, without leaking the slot", async () => {
const scheduler = new LocalLlmScheduler(1);
const gate = deferred();
const running = scheduler.run("background", undefined, () => gate.promise);
await tick();
const controller = new AbortController();
const waiting = scheduler.acquire("interactive", controller.signal);
controller.abort();
await expect(waiting).rejects.toThrow(/abort/i);
gate.resolve();
await running;
// Queue is clean: a fresh acquire succeeds immediately.
const release = await scheduler.acquire("background");
release();
});
it("rejects immediately when acquiring with an already-aborted signal", async () => {
const scheduler = new LocalLlmScheduler(1);
const controller = new AbortController();
controller.abort();
await expect(scheduler.acquire("interactive", controller.signal)).rejects.toThrow(/abort/i);
});
it("allows up to maxConcurrent tasks at once", async () => {
const scheduler = new LocalLlmScheduler(2);
const gateA = deferred();
const gateB = deferred();
let active = 0;
let peak = 0;
const track = async (gate: Promise<void>) => {
active++;
peak = Math.max(peak, active);
await gate;
active--;
};
const a = scheduler.run("background", undefined, () => track(gateA.promise));
const b = scheduler.run("background", undefined, () => track(gateB.promise));
await tick();
expect(peak).toBe(2);
gateA.resolve();
gateB.resolve();
await Promise.all([a, b]);
});
});

View file

@ -0,0 +1,252 @@
import { wrapLanguageModel, type LanguageModel } from "ai";
import type { z } from "zod";
import type { LlmProvider } from "@x/shared/dist/models.js";
import { PrefixLogger } from "@x/shared";
const log = new PrefixLogger("LocalLlm");
// 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;
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
/**
* Whether requests to this provider are served by a model running on the
* user's own machine (and therefore need scheduling: local runtimes process
* requests mostly serially, so background work must not starve chat).
*/
export function isLocalProvider(config: z.infer<typeof LlmProvider>): boolean {
if (config.flavor === "ollama") {
return true;
}
if (config.flavor === "openai-compatible") {
// LM Studio, llama.cpp server, vLLM on the same machine, etc.
try {
const host = new URL(config.baseURL ?? "").hostname;
return LOOPBACK_HOSTS.has(host) || host.endsWith(".local");
} catch {
return false;
}
}
return false;
}
export type LlmPriority = "interactive" | "classifier" | "background";
const PRIORITY_ORDER: Record<LlmPriority, number> = {
interactive: 0,
classifier: 1,
background: 2,
};
interface Waiter {
priority: LlmPriority;
seq: number;
resolve: (release: () => void) => void;
reject: (error: Error) => void;
signal?: AbortSignal;
onAbort?: () => void;
}
function abortError(): Error {
const error = new Error("local LLM slot acquisition aborted");
error.name = "AbortError";
return error;
}
/**
* Serializes access to local LLM runtimes. One slot by default: local servers
* effectively process one request at a time, and interleaving requests
* destroys their KV-cache reuse. Waiters are served strictly by priority
* (interactive chat > lightweight classifiers > background knowledge
* pipeline), FIFO within a priority so a queued email-labeling job can
* never delay the user's chat by more than the one request already running.
*/
export class LocalLlmScheduler {
private active = 0;
private seq = 0;
private readonly waiting: Waiter[] = [];
constructor(private readonly maxConcurrent = 1) {}
async acquire(priority: LlmPriority, signal?: AbortSignal): Promise<() => void> {
if (signal?.aborted) {
throw abortError();
}
if (this.active < this.maxConcurrent) {
this.active++;
return this.makeRelease();
}
return new Promise<() => void>((resolve, reject) => {
const waiter: Waiter = { priority, seq: this.seq++, resolve, reject, signal };
if (signal) {
waiter.onAbort = () => {
const index = this.waiting.indexOf(waiter);
if (index >= 0) {
this.waiting.splice(index, 1);
reject(abortError());
}
};
signal.addEventListener("abort", waiter.onAbort, { once: true });
}
this.waiting.push(waiter);
if (this.waiting.length === 1 || this.waiting.length % 10 === 0) {
log.log(`queueing ${priority} request (${this.waiting.length} waiting, ${this.active} active)`);
}
});
}
/** Run `fn` while holding a slot. */
async run<T>(priority: LlmPriority, signal: AbortSignal | undefined, fn: () => PromiseLike<T>): Promise<T> {
const release = await this.acquire(priority, signal);
try {
return await fn();
} finally {
release();
}
}
get queueDepth(): number {
return this.waiting.length;
}
private makeRelease(): () => void {
let released = false;
return () => {
if (released) {
return;
}
released = true;
this.active--;
this.dequeue();
};
}
private dequeue(): void {
while (this.active < this.maxConcurrent && this.waiting.length > 0) {
let best = 0;
for (let i = 1; i < this.waiting.length; i++) {
const a = this.waiting[i];
const b = this.waiting[best];
if (
PRIORITY_ORDER[a.priority] < PRIORITY_ORDER[b.priority] ||
(PRIORITY_ORDER[a.priority] === PRIORITY_ORDER[b.priority] && a.seq < b.seq)
) {
best = i;
}
}
const [waiter] = this.waiting.splice(best, 1);
if (waiter.signal && waiter.onAbort) {
waiter.signal.removeEventListener("abort", waiter.onAbort);
}
this.active++;
waiter.resolve(this.makeRelease());
}
}
}
function envConcurrency(): number {
const raw = process.env.ROWBOAT_LOCAL_LLM_CONCURRENCY;
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}
// One queue for all local providers: users run one local server, and even
// with several, the machine's compute is the shared resource.
export const localLlmScheduler = new LocalLlmScheduler(envConcurrency());
/**
* 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) and, when `priority` is set and the provider is local,
* goes through the shared scheduler.
*/
export function applyLocalModelSettings(
model: LanguageModel,
providerConfig: z.infer<typeof LlmProvider>,
priority: LlmPriority | null,
): LanguageModel {
if (typeof model === "string") {
// Bare model-id strings resolve through the global registry; local
// providers never take this path.
return model;
}
const local = isLocalProvider(providerConfig);
const wantsNumCtx = providerConfig.flavor === "ollama";
if (!wantsNumCtx && !(local && priority)) {
return model;
}
const numCtx = providerConfig.contextLength ?? DEFAULT_OLLAMA_CONTEXT_LENGTH;
const schedule = local && priority ? priority : null;
return wrapLanguageModel({
model,
middleware: {
...(wantsNumCtx
? {
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 },
},
},
};
},
}
: {}),
...(schedule
? {
wrapGenerate: async ({ doGenerate, params }) =>
localLlmScheduler.run(schedule, params.abortSignal, () => doGenerate()),
wrapStream: async ({ doStream, params }) => {
const release = await localLlmScheduler.acquire(schedule, params.abortSignal);
try {
const { stream, ...rest } = await doStream();
return { ...rest, stream: releaseOnSettled(stream, release) };
} catch (error) {
release();
throw error;
}
},
}
: {}),
},
});
}
// Hold the slot until the provider stream drains, errors, or is cancelled —
// a streaming response occupies the local server for its full duration.
function releaseOnSettled<T>(stream: ReadableStream<T>, release: () => void): ReadableStream<T> {
const reader = stream.getReader();
return new ReadableStream<T>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
release();
controller.close();
} else {
controller.enqueue(value);
}
} catch (error) {
release();
controller.error(error);
}
},
cancel(reason) {
release();
return reader.cancel(reason);
},
});
}

View file

@ -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,
isLocalProvider,
DEFAULT_OLLAMA_CONTEXT_LENGTH,
type LlmPriority,
} from "./local.js";
export const Provider = LlmProvider;
export const ModelConfig = LlmModelConfig;
@ -74,24 +80,149 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
}
}
/**
* The one place model instances are created. Applies local-runtime settings
* (explicit Ollama context window) and, when `priority` is given, routes the
* call through the shared local scheduler so background work cannot starve
* interactive chat. Pass `priority: null` when the caller does its own
* scheduling (the turn runtime's model registry).
*/
export function createLanguageModel(
providerConfig: z.infer<typeof Provider>,
modelId: string,
opts: { priority?: LlmPriority | null } = {},
): LanguageModel {
const model = createProvider(providerConfig).languageModel(modelId);
return applyLocalModelSettings(model, providerConfig, opts.priority ?? null);
}
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" && isLocalProvider(providerConfig)) {
// LM Studio's enhanced REST API lives at /api/v0 on the same
// origin as the OpenAI-compatible /v1 endpoint.
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 +334,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, { priority: "interactive" });
const result = await withUseCase(
{ useCase: "copilot_chat", subUseCase: "email_compose" },
() => generateText({

View file

@ -48,6 +48,10 @@ export class FSModelConfigRepo implements IModelConfigRepo {
apiKey: config.provider.apiKey,
baseURL: config.provider.baseURL,
headers: config.provider.headers,
// Preserve a hand-edited contextLength unless the caller sets one.
...(config.provider.contextLength !== undefined
? { contextLength: config.provider.contextLength }
: {}),
model: config.model,
models: config.models,
knowledgeGraphModel: config.knowledgeGraphModel,

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

View 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;
}

View file

@ -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 { createLanguageModel } from "../models/models.js";
import { generateObjectSafe } from "../models/structured.js";
const DecisionSchema = z.object({
decisions: z.array(z.object({
@ -83,7 +84,7 @@ export async function classifyToolPermissions(input: {
const modelId = await getAutoPermissionDecisionModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const providerConfig = await resolveProviderConfig(providerName);
const model = createProvider(providerConfig).languageModel(modelId);
const model = createLanguageModel(providerConfig, modelId, { priority: "classifier" });
const result = await withUseCase(
{
@ -91,11 +92,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,
}),
);

View file

@ -14,6 +14,11 @@ 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,
isLocalProvider,
localLlmScheduler,
} from "../../models/local.js";
import type {
IModelRegistry,
LlmStreamEvent,
@ -29,6 +34,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 +70,15 @@ 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, but
// scheduling happens per-step in run() where the turn's priority is
// known — so priority stays null.
const model = applyLocalModelSettings(
provider.languageModel(descriptor.model),
providerConfig,
null,
);
const local = isLocalProvider(providerConfig);
return {
descriptor,
// The structural -> wire conversion the app uses today: weaves
@ -70,13 +87,14 @@ export class RealModelRegistry implements IModelRegistry {
// per-message, so composed requests are byte-stable.
encodeMessages: (messages) =>
convertFromMessages(messages) as unknown as JsonValue[],
stream: (request) => this.run(model, request),
stream: (request) => this.run(model, request, local),
};
}
private async *run(
model: LanguageModel,
request: ModelStreamRequest,
local: boolean,
): AsyncGenerator<LlmStreamEvent, void, void> {
const tools: ToolSet = {};
for (const descriptor of request.tools) {
@ -93,13 +111,26 @@ 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>> }
: {}),
};
// One scheduler slot per model step: local runtimes serve requests
// serially, and headless turns must not starve interactive chat.
const release = local
? await localLlmScheduler.acquire(
request.priority ?? "interactive",
request.signal,
)
: null;
const parts: Array<z.infer<typeof AssistantContentPart>> = [];
let textBuffer = "";
@ -108,99 +139,112 @@ export class RealModelRegistry implements IModelRegistry {
let usage: z.infer<typeof TurnUsage> = {};
let providerMetadata: JsonValue | undefined;
for await (const raw of result.fullStream) {
request.signal.throwIfAborted();
const event = raw as {
type: string;
text?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
finishReason?: string;
usage?: Record<string, number | undefined>;
providerMetadata?: unknown;
error?: unknown;
};
switch (event.type) {
case "text-start":
textBuffer = "";
yield { type: "step_event", event: { type: "text_start" } };
break;
case "text-delta": {
const delta = event.text ?? "";
textBuffer += delta;
const last = parts[parts.length - 1];
if (last?.type === "text") {
last.text += delta;
} else {
parts.push({ type: "text", text: delta });
try {
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 {
type: string;
text?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
finishReason?: string;
usage?: Record<string, number | undefined>;
providerMetadata?: unknown;
error?: unknown;
};
switch (event.type) {
case "text-start":
textBuffer = "";
yield { type: "step_event", event: { type: "text_start" } };
break;
case "text-delta": {
const delta = event.text ?? "";
textBuffer += delta;
const last = parts[parts.length - 1];
if (last?.type === "text") {
last.text += delta;
} else {
parts.push({ type: "text", text: delta });
}
yield { type: "text_delta", delta };
break;
}
yield { type: "text_delta", delta };
break;
}
case "text-end":
yield {
type: "step_event",
event: { type: "text_end", text: textBuffer },
};
break;
case "reasoning-start":
reasoningBuffer = "";
yield { type: "step_event", event: { type: "reasoning_start" } };
break;
case "reasoning-delta": {
const delta = event.text ?? "";
reasoningBuffer += delta;
const last = parts[parts.length - 1];
if (last?.type === "reasoning") {
last.text += delta;
} else {
parts.push({ type: "reasoning", text: delta });
case "text-end":
yield {
type: "step_event",
event: { type: "text_end", text: textBuffer },
};
break;
case "reasoning-start":
reasoningBuffer = "";
yield { type: "step_event", event: { type: "reasoning_start" } };
break;
case "reasoning-delta": {
const delta = event.text ?? "";
reasoningBuffer += delta;
const last = parts[parts.length - 1];
if (last?.type === "reasoning") {
last.text += delta;
} else {
parts.push({ type: "reasoning", text: delta });
}
yield { type: "reasoning_delta", delta };
break;
}
yield { type: "reasoning_delta", delta };
break;
case "reasoning-end":
yield {
type: "step_event",
event: { type: "reasoning_end", text: reasoningBuffer },
};
break;
case "tool-call": {
const toolCall = {
type: "tool-call" as const,
toolCallId: String(event.toolCallId),
toolName: String(event.toolName),
arguments: event.input,
};
parts.push(toolCall);
yield { type: "step_event", event: { type: "tool_call", toolCall } };
break;
}
case "finish-step": {
finishReason = event.finishReason ?? "unknown";
usage = mapUsage(event.usage);
providerMetadata = toJsonValue(event.providerMetadata);
yield {
type: "step_event",
event: {
type: "finish_step",
finishReason,
usage,
...(providerMetadata === undefined
? {}
: { providerMetadata }),
},
};
break;
}
case "error":
throw event.error instanceof Error
? event.error
: new Error(formatStreamError(event.error));
default:
break;
}
case "reasoning-end":
yield {
type: "step_event",
event: { type: "reasoning_end", text: reasoningBuffer },
};
break;
case "tool-call": {
const toolCall = {
type: "tool-call" as const,
toolCallId: String(event.toolCallId),
toolName: String(event.toolName),
arguments: event.input,
};
parts.push(toolCall);
yield { type: "step_event", event: { type: "tool_call", toolCall } };
break;
}
case "finish-step": {
finishReason = event.finishReason ?? "unknown";
usage = mapUsage(event.usage);
providerMetadata = toJsonValue(event.providerMetadata);
yield {
type: "step_event",
event: {
type: "finish_step",
finishReason,
usage,
...(providerMetadata === undefined
? {}
: { providerMetadata }),
},
};
break;
}
case "error":
throw event.error instanceof Error
? event.error
: new Error(formatStreamError(event.error));
default:
break;
}
} finally {
release?.();
}
yield {

View file

@ -34,6 +34,10 @@ export interface ModelStreamRequest {
tools: Array<z.infer<typeof ToolDescriptor>>;
parameters: Record<string, JsonValue>;
signal: AbortSignal;
// Scheduling class for local-model queueing: interactive turns (a human
// is watching) preempt queued headless/background work. Defaults to
// "interactive" so an unset priority never slows a user down.
priority?: "interactive" | "background";
}
export interface ResolvedModel {

View file

@ -951,6 +951,11 @@ class TurnAdvance {
tools: composed.tools,
parameters: composed.parameters,
signal: this.signal,
// Headless turns (no human waiting) yield the local-model
// queue to interactive chat.
priority: this.definition.config.humanAvailable
? "interactive"
: "background",
})) {
switch (event.type) {
case "text_delta":

View file

@ -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': {

View file

@ -5,6 +5,10 @@ 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(),
});
export const LlmModelConfig = z.object({
@ -15,6 +19,7 @@ export const LlmModelConfig = z.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
contextLength: z.number().int().positive().optional(),
model: z.string().optional(),
models: z.array(z.string()).optional(),
knowledgeGraphModel: z.string().optional(),