mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
refactor(x)!: models.json v2 — providers carry credentials, models live in assistantModel/taskModels
The v1 config fossilized three schema generations: a top-level
provider/model pair (single-provider era), a providers map duplicating
credentials AND caching model lists (pre-dynamic-listing era), plus
defaultSelection and flat category overrides (hybrid era) — while several
effective models existed only as hidden curated branches in code.
v2 is the intended shape:
{ version: 2,
providers: { <instance-id>: { flavor, apiKey?, baseURL?, … } },
assistantModel: { provider, model },
taskModels: { knowledgeGraph?, meetingNotes?, liveNoteAgent?,
autoPermissionDecision?, chatTitle? },
deferBackgroundTasks? }
- one-time boot migration (core/models/migrate.ts, invoked from
ensureConfig): evaluates the v1 resolution rules — including the curated
signed-in defaults — one last time and writes the answers explicitly, so
the simplified resolvers produce identical effective models; task
overrides are written only where the old effective model differs from
inherit-from-assistant; curated ids survive solely as frozen literals in
the migration
- defaults.ts: getDefaultModelAndProvider reads assistantModel, period;
getCategoryModel/getChatTitleModel are "override else assistant"; all
SIGNED_IN_* constants and the repo's gpt-5.4 bootstrap deleted
- rowboat sign-in = connecting a provider: with no saved assistant, the
initial model is picked via selectInitialModel (backend recommendation
if the gateway lists it, else first listed) and saved; sign-out clears
rowboat-referencing selections (same dangling-ref cleanup as removing
any provider). A saved assistant is never replaced — signing in no
longer hijacks a BYOK selection
- IPC: models:saveConfig replaced by models:setProvider/removeProvider;
models:updateConfig speaks v2 keys; models:test takes {provider, model}
- renderer call sites (settings dialog, onboarding, composer) translated;
the settings dialog's delete-provider path collapses from ~100 lines of
top-level-pair juggling to removeProvider + best-effort promotion
- migration dry-run verified against a real-world signed-in config
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b0000a10d9
commit
f39daa9f5d
23 changed files with 938 additions and 539 deletions
|
|
@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
|
||||
import { ListToolsResponse } from './mcp.js';
|
||||
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
|
||||
import { LlmModelConfig, LlmProvider, ModelOverride, ModelRef, ReasoningEffort } from './models.js';
|
||||
import { LlmProvider, ModelRef, ReasoningEffort } from './models.js';
|
||||
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
|
||||
import { AgentScheduleState } from './agent-schedule-state.js';
|
||||
import { ServiceEvent } from './service-events.js';
|
||||
|
|
@ -667,8 +667,6 @@ const ipcSchemas = {
|
|||
// 'error' = provider is connected but its model list failed to load.
|
||||
status: z.enum(['ok', 'error']),
|
||||
error: z.string().optional(),
|
||||
// The provider's saved default model from models.json, if any.
|
||||
savedModel: z.string().optional(),
|
||||
models: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
|
|
@ -682,7 +680,10 @@ const ipcSchemas = {
|
|||
}),
|
||||
},
|
||||
'models:test': {
|
||||
req: LlmModelConfig,
|
||||
req: z.object({
|
||||
provider: LlmProvider,
|
||||
model: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
|
|
@ -726,23 +727,40 @@ const ipcSchemas = {
|
|||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'models:saveConfig': {
|
||||
req: LlmModelConfig,
|
||||
// Upsert one provider entry (credentials + connection prefs). Model
|
||||
// choices are NOT part of a provider — set them via models:updateConfig.
|
||||
'models:setProvider': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
provider: LlmProvider,
|
||||
}),
|
||||
res: z.object({
|
||||
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.
|
||||
// Remove a provider entry plus any assistantModel / task override that
|
||||
// references it (dangling selections would just error at run time).
|
||||
'models:removeProvider': {
|
||||
req: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Partial merge of model selections into models.json. Omitted keys are
|
||||
// untouched; null clears a key (a cleared task override inherits the
|
||||
// assistant model again). taskModels merges per-key.
|
||||
'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(),
|
||||
assistantModel: ModelRef.nullable().optional(),
|
||||
taskModels: z.object({
|
||||
knowledgeGraph: ModelRef.nullable().optional(),
|
||||
meetingNotes: ModelRef.nullable().optional(),
|
||||
liveNoteAgent: ModelRef.nullable().optional(),
|
||||
autoPermissionDecision: ModelRef.nullable().optional(),
|
||||
chatTitle: ModelRef.nullable().optional(),
|
||||
}).optional(),
|
||||
deferBackgroundTasks: z.boolean().nullable().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { z } from "zod";
|
|||
// thinkingLevel, OpenRouter reasoning.effort) is mapped at invoke time.
|
||||
export const ReasoningEffort = z.enum(["low", "medium", "high"]);
|
||||
|
||||
// A provider entry: its TYPE (flavor) plus credentials and connection
|
||||
// preferences. Deliberately carries NO model fields — model lists are always
|
||||
// fetched from the provider (core/models/catalog.ts), and model choices live
|
||||
// in assistantModel / taskModels.
|
||||
export const LlmProvider = z.object({
|
||||
// "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via
|
||||
// "Sign in with ChatGPT") are credential-less flavors: they never appear
|
||||
|
|
@ -28,51 +32,48 @@ export const LlmProvider = z.object({
|
|||
reasoningEffort: ReasoningEffort.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.
|
||||
// A provider-qualified model reference. `provider` is a provider INSTANCE id
|
||||
// as understood by resolveProviderConfig — a key of the providers map, or
|
||||
// "rowboat" / "codex" for the credential-less providers. Today one instance
|
||||
// exists per flavor, so instance ids equal flavor keys.
|
||||
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]);
|
||||
// The per-task model override slots. Absence = inherit the assistant model.
|
||||
export const TaskModels = z.object({
|
||||
knowledgeGraph: ModelRef.optional(),
|
||||
meetingNotes: ModelRef.optional(),
|
||||
liveNoteAgent: ModelRef.optional(),
|
||||
autoPermissionDecision: ModelRef.optional(),
|
||||
chatTitle: ModelRef.optional(),
|
||||
});
|
||||
export type TaskModelKey = keyof z.infer<typeof TaskModels>;
|
||||
|
||||
/**
|
||||
* models.json, version 2.
|
||||
*
|
||||
* The design: providers carry credentials only (keyed by instance id, with
|
||||
* the flavor explicit inside each entry); model choices live in exactly two
|
||||
* places — the required-once-configured `assistantModel`, and optional
|
||||
* per-task overrides that otherwise inherit from it. Model LISTS are never
|
||||
* stored: they are fetched live per provider by the unified catalog.
|
||||
*
|
||||
* Version 1 (top-level provider/model pair + per-provider model lists +
|
||||
* defaultSelection + flat category overrides) is migrated on boot by
|
||||
* core/models/migrate.ts and its schema lives there.
|
||||
*/
|
||||
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(),
|
||||
version: z.literal(2),
|
||||
providers: z.record(z.string(), LlmProvider),
|
||||
// The one primary model choice: what runs when nothing more specific was
|
||||
// picked. Absent only before onboarding / first provider connect.
|
||||
assistantModel: ModelRef.optional(),
|
||||
taskModels: TaskModels.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: ReasoningEffort.optional(),
|
||||
model: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
knowledgeGraphModel: z.string().optional(),
|
||||
meetingNotesModel: z.string().optional(),
|
||||
liveNoteAgentModel: z.string().optional(),
|
||||
autoPermissionDecisionModel: z.string().optional(),
|
||||
})).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(),
|
||||
chatTitleModel: ModelOverride.optional(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue