feat(x): apply per-task model recommendations at initial selection

The backend's modelRecommendations entries are now { assistantModel,
taskModels? } (rowboatx-backend#18), mirroring models.json v2 vocabulary.
When a provider connect/sign-in seeds the assistant (and ONLY then), its
per-task recommendations become visible taskModels overrides — each
validated against the live list and skipped when equal to the chosen
assistant (only differences are written, same principle as the migration).

This restores pre-v2 economics for signed-in task models without hidden
defaults: fresh Rowboat sign-ins, new devices, and sign-out→sign-in round
trips all land with the lite-tier task models the app used to hardcode —
server-controlled, updateable without a release. Saved choices are still
never touched: no seeding happens when an assistant already exists.

- selection logic moves to @x/shared/initial-selection (core re-exports;
  the renderer connect flow uses the same implementation)
- RowboatApiConfig accepts both the legacy flat shape and the nested one
  (normalizeModelRecommendation), so backend deploy order and rollback
  are non-events
- llm_initial_model_selected gains task_overrides_seeded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-24 17:33:59 +05:30
parent d9317f3ca1
commit 72e71e0ab5
9 changed files with 193 additions and 59 deletions

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

View file

@ -11,13 +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(),
// One recommended model id per provider FLAVOR (e.g. { openai: "gpt-5.4",
// openrouter: "anthropic/claude-opus-4.8" }), in each provider's native id
// format. A hint for the INITIAL selection when a provider is first
// 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 core/models/initial-selection.ts). 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.string()).optional(),
// (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 ?? {} };
}