mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
Merge pull request #791 from rowboatlabs/model-selection
One model-selection experience: providers own catalogs, one Assistant model, split-view picker
This commit is contained in:
commit
5c330de3fa
52 changed files with 3624 additions and 2511 deletions
67
apps/x/packages/shared/src/initial-selection.ts
Normal file
67
apps/x/packages/shared/src/initial-selection.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { z } from "zod";
|
||||
import { ModelRef, TaskModels } from "./models.js";
|
||||
import { normalizeModelRecommendation, type ModelRecommendations } from "./rowboat-account.js";
|
||||
|
||||
/**
|
||||
* Initial model selection for a provider being connected for the first time.
|
||||
*
|
||||
* Implements the selection order from the provider/model-selection spec:
|
||||
* 1. If Rowboat's recommended model for this flavor appears in the
|
||||
* provider's available list, pick it.
|
||||
* 2. Otherwise pick the first model the provider returned.
|
||||
* 3. With no list at all, return null — the caller offers retry or manual
|
||||
* entry.
|
||||
*
|
||||
* Task-model recommendations ride along the same moment: when (and only
|
||||
* when) a connect seeds the assistant, the provider's per-task
|
||||
* recommendations become visible taskModels overrides — each validated
|
||||
* against the live list and skipped when it equals the chosen assistant
|
||||
* (inheritance already produces it; only differences are written).
|
||||
*
|
||||
* This runs ONLY when a provider is first connected and has no saved
|
||||
* selection. It must never run over an existing choice: after initial setup
|
||||
* the saved model configuration is the source of truth, and changes to the
|
||||
* recommendations or to the provider's list order must not silently replace
|
||||
* what the user picked.
|
||||
*
|
||||
* Pure functions by design: callers supply the provider's available models
|
||||
* (from the unified catalog / a live probe) and the recommendations map
|
||||
* (from /v1/config via rowboat:getConfig, keyed by provider FLAVOR in each
|
||||
* provider's native id format). Everything is best-effort — an absent map,
|
||||
* an unknown flavor, or a recommendation the provider doesn't serve all
|
||||
* degrade gracefully.
|
||||
*/
|
||||
|
||||
const TASK_MODEL_KEYS = Object.keys(TaskModels.shape) as Array<keyof z.infer<typeof TaskModels>>;
|
||||
|
||||
export function selectInitialModel(
|
||||
flavor: string,
|
||||
availableModelIds: string[],
|
||||
recommendations: ModelRecommendations | undefined,
|
||||
): string | null {
|
||||
const recommended = normalizeModelRecommendation(recommendations, flavor)?.assistantModel;
|
||||
if (recommended && availableModelIds.includes(recommended)) {
|
||||
return recommended;
|
||||
}
|
||||
return availableModelIds[0] ?? null;
|
||||
}
|
||||
|
||||
export function selectInitialTaskModels(
|
||||
providerId: string,
|
||||
flavor: string,
|
||||
availableModelIds: string[],
|
||||
recommendations: ModelRecommendations | undefined,
|
||||
assistantModel: string,
|
||||
): Partial<Record<keyof z.infer<typeof TaskModels>, z.infer<typeof ModelRef>>> {
|
||||
const taskRecommendations = normalizeModelRecommendation(recommendations, flavor)?.taskModels;
|
||||
if (!taskRecommendations) return {};
|
||||
const overrides: Partial<Record<keyof z.infer<typeof TaskModels>, z.infer<typeof ModelRef>>> = {};
|
||||
for (const key of TASK_MODEL_KEYS) {
|
||||
const model = taskRecommendations[key];
|
||||
// Unknown keys are ignored; a rec equal to the assistant is redundant
|
||||
// (inherit produces it); an unlisted rec is a stale hint.
|
||||
if (!model || model === assistantModel || !availableModelIds.includes(model)) continue;
|
||||
overrides[key] = { provider: providerId, model };
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -646,26 +646,44 @@ const ipcSchemas = {
|
|||
req: BackgroundTaskAgentEvent,
|
||||
res: z.null(),
|
||||
},
|
||||
// The unified model catalog (core/models/catalog.ts): every connected
|
||||
// provider — Rowboat gateway, ChatGPT subscription (codex), BYOK keys,
|
||||
// local/custom endpoints — listed the same way, with per-provider status.
|
||||
'models:list': {
|
||||
req: z.null(),
|
||||
req: z.object({
|
||||
// Drop this provider's cached list and refetch (Retry / Refresh).
|
||||
refreshProvider: z.string().optional(),
|
||||
}).nullable(),
|
||||
res: z.object({
|
||||
providers: z.array(z.object({
|
||||
// Provider INSTANCE id — what ModelRef.provider / assistantModel /
|
||||
// refreshProvider reference. One instance per flavor today, so it
|
||||
// always equals the flavor key; kept distinct so a future
|
||||
// multi-instance setup ("openai-work") is additive.
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
// Provider TYPE ("openai", "ollama", "rowboat", "codex", …) —
|
||||
// drives display naming and credential-field UI.
|
||||
flavor: z.string(),
|
||||
// 'error' = provider is connected but its model list failed to load.
|
||||
status: z.enum(['ok', 'error']),
|
||||
error: z.string().optional(),
|
||||
models: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
release_date: z.string().optional(),
|
||||
// models.dev "supports reasoning/extended thinking" flag; absent =
|
||||
// unknown. Gates the composer's reasoning-effort control.
|
||||
reasoning: z.boolean().optional(),
|
||||
})),
|
||||
})),
|
||||
lastUpdated: z.string().optional(),
|
||||
// The effective runtime default (what runs when nothing is picked).
|
||||
defaultModel: ModelRef.nullable(),
|
||||
}),
|
||||
},
|
||||
'models:test': {
|
||||
req: LlmModelConfig,
|
||||
req: z.object({
|
||||
provider: LlmProvider,
|
||||
model: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
|
|
@ -709,23 +727,69 @@ 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),
|
||||
}),
|
||||
},
|
||||
// Current model selections plus credential-FREE provider metadata (the
|
||||
// renderer never needs keys to render pickers or provider cards). Null
|
||||
// assistantModel = not configured yet.
|
||||
'models:getConfig': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
// Configured BYOK provider entries, secrets stripped: enough to
|
||||
// render manage/edit surfaces (masked key indicator, endpoint).
|
||||
providers: z.array(z.object({
|
||||
id: z.string(),
|
||||
flavor: z.string(),
|
||||
baseURL: z.string().optional(),
|
||||
hasApiKey: z.boolean(),
|
||||
})),
|
||||
assistantModel: ModelRef.nullable(),
|
||||
taskModels: z.object({
|
||||
knowledgeGraph: ModelRef.nullable(),
|
||||
meetingNotes: ModelRef.nullable(),
|
||||
liveNoteAgent: ModelRef.nullable(),
|
||||
autoPermissionDecision: ModelRef.nullable(),
|
||||
chatTitle: ModelRef.nullable(),
|
||||
backgroundTask: ModelRef.nullable(),
|
||||
subagent: ModelRef.nullable(),
|
||||
}),
|
||||
deferBackgroundTasks: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// 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(),
|
||||
backgroundTask: ModelRef.nullable().optional(),
|
||||
subagent: ModelRef.nullable().optional(),
|
||||
}).optional(),
|
||||
deferBackgroundTasks: z.boolean().nullable().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
|
|
@ -773,9 +837,16 @@ const ipcSchemas = {
|
|||
res: z.object({
|
||||
signedIn: z.boolean(),
|
||||
accessToken: z.string().nullable(),
|
||||
config: RowboatApiConfig.nullable(),
|
||||
}),
|
||||
},
|
||||
// The unauthenticated /v1/config bootstrap (service URLs, billing catalog,
|
||||
// model recommendations). Independent of sign-in state — main caches the
|
||||
// fetch once per app run; null when the API is unreachable. Renderer
|
||||
// consumers go through the useRowboatConfig() hook.
|
||||
'rowboat:getConfig': {
|
||||
req: z.null(),
|
||||
res: RowboatApiConfig.nullable(),
|
||||
},
|
||||
'oauth:didConnect': {
|
||||
req: z.object({
|
||||
provider: z.string(),
|
||||
|
|
|
|||
|
|
@ -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,52 @@ 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
|
||||
// (except `subagent`, whose default is the PARENT turn's model — which is
|
||||
// the assistant for a top-level chat).
|
||||
export const TaskModels = z.object({
|
||||
knowledgeGraph: ModelRef.optional(),
|
||||
meetingNotes: ModelRef.optional(),
|
||||
liveNoteAgent: ModelRef.optional(),
|
||||
autoPermissionDecision: ModelRef.optional(),
|
||||
chatTitle: ModelRef.optional(),
|
||||
backgroundTask: ModelRef.optional(),
|
||||
subagent: 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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,4 +11,43 @@ export const RowboatApiConfig = z.object({
|
|||
// app keeps working against API deployments that predate it — the rewards
|
||||
// UI just stays empty until the backend serves the catalog
|
||||
creditActivations: z.array(CreditActivationCatalogEntrySchema).optional(),
|
||||
// Recommended models per provider FLAVOR, in each provider's native id
|
||||
// format: one assistantModel (the primary) plus optional per-task
|
||||
// taskModels overrides mirroring models.json v2 vocabulary (a missing
|
||||
// task key = inherit the assistant — task recs exist only where the
|
||||
// intended model differs; for rowboat they reproduce the pre-v2 curated
|
||||
// lite-tier task models so plan credits aren't burned by background
|
||||
// services). Hints for the INITIAL selection when a provider is first
|
||||
// connected — never a catalog, and never applied over a saved choice
|
||||
// (see shared/initial-selection.ts). The bare-string form is the legacy
|
||||
// wire shape, accepted so backend deploy order and rollback are
|
||||
// non-events. Local/custom flavors are intentionally absent: the API
|
||||
// can't know which models exist in a user's environment. Optional so
|
||||
// older API deployments and failed fetches never break parsing —
|
||||
// recommendations are best-effort by design.
|
||||
modelRecommendations: z.record(z.string(), z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
assistantModel: z.string(),
|
||||
taskModels: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
])).optional(),
|
||||
});
|
||||
|
||||
export type ModelRecommendations = NonNullable<z.infer<typeof RowboatApiConfig>['modelRecommendations']>;
|
||||
|
||||
export interface NormalizedModelRecommendation {
|
||||
assistantModel: string;
|
||||
taskModels: Record<string, string>;
|
||||
}
|
||||
|
||||
/** One provider's recommendation in canonical form; null when absent. */
|
||||
export function normalizeModelRecommendation(
|
||||
recommendations: ModelRecommendations | undefined,
|
||||
flavor: string,
|
||||
): NormalizedModelRecommendation | null {
|
||||
const raw = recommendations?.[flavor];
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'string') return { assistantModel: raw, taskModels: {} };
|
||||
return { assistantModel: raw.assistantModel, taskModels: raw.taskModels ?? {} };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue