Let users choose the metadata generation model (#3215)

Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish.
This commit is contained in:
Mohamed Boudra
2026-08-11 14:01:00 +02:00
committed by GitHub
parent b599d38a77
commit 36a13601dd
19 changed files with 466 additions and 66 deletions
@@ -0,0 +1,110 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectSettingsHeader,
openHostSection,
openSettingsHost,
} from "../support/helpers/settings";
async function openMetadataGenerationSettings(page: Page) {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "metadata");
await expectSettingsHeader(page, "Metadata");
}
async function openManualMetadataModelPicker(page: Page) {
await page.getByRole("button", { name: "Manual", exact: true }).click();
await page.getByRole("button", { name: /Select model/ }).click();
}
test("chooses a metadata model and can return to automatic selection", async ({
page,
}, testInfo) => {
await openMetadataGenerationSettings(page);
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await page.screenshot({
path: testInfo.outputPath("metadata-automatic.png"),
fullPage: true,
});
await openManualMetadataModelPicker(page);
await page.getByText("Mock Load Test", { exact: true }).click();
await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("metadata-model-picker.png"),
fullPage: true,
});
await page.getByText("Ten second stream", { exact: true }).click();
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
await page.reload();
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("metadata-manual-persisted.png"),
fullPage: true,
});
await page.getByRole("button", { name: "Automatic", exact: true }).click();
await page.reload();
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0);
});
test("replaces only the first configured metadata model", async ({ page }) => {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({
metadataGeneration: {
providers: [
{ provider: "mock", model: "five-minute-stream" },
{ provider: "mock", model: "thirty-minute-stream" },
],
},
});
await openMetadataGenerationSettings(page);
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await openManualMetadataModelPicker(page);
await page.getByText("Ten second stream", { exact: true }).click();
await expect
.poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers)
.toEqual([
{ provider: "mock", model: "ten-second-stream" },
{ provider: "mock", model: "thirty-minute-stream" },
]);
} finally {
try {
await client.patchDaemonConfig({
metadataGeneration: {
providers: previousConfig.config.metadataGeneration.providers,
},
});
} finally {
await client.close().catch(() => undefined);
}
}
});
@@ -36,6 +36,7 @@ type HostSection =
| "connections"
| "pair-device"
| "agents"
| "metadata"
| "workspaces"
| "providers"
| "usage"
@@ -384,12 +384,12 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
alignItems: "flex-start",
gap: theme.spacing[3],
},
leading: {
width: PROVIDER_ICON_SIZE,
height: PROVIDER_ICON_SIZE,
height: 20,
alignItems: "center",
justifyContent: "center",
},
+15
View File
@@ -1762,12 +1762,27 @@ export const ar: TranslationResources = {
projects: "المشاريع",
connections: "اتصالات",
agents: "Agents",
metadata: "البيانات الوصفية",
workspaces: "Workspaces",
providers: "مقدمي الخدمات",
usage: "الاستخدام",
terminals: "Terminals",
host: "نظرة عامة",
},
metadataGeneration: {
title: "إنشاء البيانات الوصفية",
description:
"اختر النموذج الذي يستخدمه Paseo لعناوين مساحات العمل وأسماء الفروع ورسائل الالتزام ومسودات طلبات السحب",
selection: "اختيار النموذج",
automatic: "تلقائي",
preferred: "يدوي",
automaticHint: "يختار Paseo نموذجًا سريعًا متاحًا",
preferredHint: "اختر النموذج الذي يستخدمه Paseo",
model: "النموذج",
fallbackHint: "إذا لم يكن متاحًا، يستخدم Paseo نموذجًا آخر متاحًا",
docs: "الوثائق",
saveError: "تعذر تحديث إنشاء البيانات الوصفية",
},
general: {
title: "عام",
browserData: {
+15
View File
@@ -1773,12 +1773,27 @@ export const en = {
projects: "Projects",
connections: "Connections",
agents: "Agents",
metadata: "Metadata",
workspaces: "Workspaces",
providers: "Providers",
usage: "Usage",
terminals: "Terminals",
host: "Overview",
},
metadataGeneration: {
title: "Metadata generation",
description:
"Choose the model Paseo uses for workspace titles, branch names, commit messages, and pull request drafts",
selection: "Model selection",
automatic: "Automatic",
preferred: "Manual",
automaticHint: "Paseo picks a fast available model",
preferredHint: "Choose the model Paseo uses",
model: "Model",
fallbackHint: "If it is unavailable, Paseo falls back to another available model",
docs: "Docs",
saveError: "Unable to update metadata generation",
},
general: {
title: "General",
browserData: {
+15
View File
@@ -1805,12 +1805,27 @@ export const es: TranslationResources = {
projects: "Proyectos",
connections: "Conexiones",
agents: "Agents",
metadata: "Metadatos",
workspaces: "Workspaces",
providers: "Proveedores",
usage: "Uso",
terminals: "Terminals",
host: "Resumen",
},
metadataGeneration: {
title: "Generación de metadatos",
description:
"Elige el modelo que Paseo usa para títulos de espacios de trabajo, nombres de ramas, mensajes de commit y borradores de pull requests",
selection: "Selección de modelo",
automatic: "Automática",
preferred: "Manual",
automaticHint: "Paseo elige un modelo rápido disponible",
preferredHint: "Elige el modelo que usa Paseo",
model: "Modelo",
fallbackHint: "Si no está disponible, Paseo usa otro modelo disponible",
docs: "Documentación",
saveError: "No se pudo actualizar la generación de metadatos",
},
general: {
title: "General",
browserData: {
+15
View File
@@ -1809,12 +1809,27 @@ export const fr: TranslationResources = {
projects: "Projets",
connections: "Relations",
agents: "Agents",
metadata: "Métadonnées",
workspaces: "Workspaces",
providers: "Fournisseurs",
usage: "Utilisation",
terminals: "Terminals",
host: "Aperçu",
},
metadataGeneration: {
title: "Génération de métadonnées",
description:
"Choisissez le modèle utilisé par Paseo pour les titres d’espaces de travail, les noms de branches, les messages de commit et les brouillons de pull request",
selection: "Sélection du modèle",
automatic: "Automatique",
preferred: "Manuel",
automaticHint: "Paseo choisit un modèle rapide disponible",
preferredHint: "Choisissez le modèle utilisé par Paseo",
model: "Modèle",
fallbackHint: "S’il est indisponible, Paseo utilise un autre modèle disponible",
docs: "Documentation",
saveError: "Impossible de mettre à jour la génération de métadonnées",
},
general: {
title: "Général",
browserData: {
+15
View File
@@ -1778,12 +1778,27 @@ export const ja: TranslationResources = {
projects: "プロジェクト",
connections: "接続",
agents: "エージェント",
metadata: "メタデータ",
workspaces: "ワークスペース",
providers: "プロバイダー",
usage: "使用状況",
terminals: "ターミナル",
host: "概要",
},
metadataGeneration: {
title: "メタデータ生成",
description:
"ワークスペースのタイトル、ブランチ名、コミットメッセージ、プルリクエストの下書きに使用するモデルを選択します",
selection: "モデル選択",
automatic: "自動",
preferred: "手動",
automaticHint: "Paseo が利用可能な高速モデルを選択します",
preferredHint: "Paseo が使用するモデルを選択します",
model: "モデル",
fallbackHint: "利用できない場合、Paseo は別の利用可能なモデルを使用します",
docs: "ドキュメント",
saveError: "メタデータ生成を更新できません",
},
general: {
title: "一般",
browserData: {
+15
View File
@@ -1772,12 +1772,27 @@ export const ko: TranslationResources = {
projects: "프로젝트",
connections: "연결",
agents: "에이전트",
metadata: "메타데이터",
workspaces: "워크스페이스",
providers: "프로바이더",
usage: "사용량",
terminals: "터미널",
host: "개요",
},
metadataGeneration: {
title: "메타데이터 생성",
description:
"워크스페이스 제목, 브랜치 이름, 커밋 메시지 및 풀 리퀘스트 초안에 사용할 모델을 선택하세요",
selection: "모델 선택",
automatic: "자동",
preferred: "수동",
automaticHint: "Paseo가 사용 가능한 빠른 모델을 선택합니다",
preferredHint: "Paseo에서 사용할 모델을 선택하세요",
model: "모델",
fallbackHint: "사용할 수 없으면 Paseo가 다른 사용 가능한 모델을 사용합니다",
docs: "문서",
saveError: "메타데이터 생성을 업데이트할 수 없습니다",
},
general: {
title: "일반",
browserData: {
+15
View File
@@ -1791,12 +1791,27 @@ export const ptBR: TranslationResources = {
projects: "Projetos",
connections: "Conexões",
agents: "Agentes",
metadata: "Metadados",
workspaces: "Workspaces",
providers: "Provedores",
usage: "Uso",
terminals: "Terminais",
host: "Visão geral",
},
metadataGeneration: {
title: "Geração de metadados",
description:
"Escolha o modelo usado pelo Paseo para títulos de espaços de trabalho, nomes de branches, mensagens de commit e rascunhos de pull request",
selection: "Seleção de modelo",
automatic: "Automática",
preferred: "Manual",
automaticHint: "O Paseo escolhe um modelo rápido disponível",
preferredHint: "Escolha o modelo que o Paseo usa",
model: "Modelo",
fallbackHint: "Se ele não estiver disponível, o Paseo usa outro modelo disponível",
docs: "Documentação",
saveError: "Não foi possível atualizar a geração de metadados",
},
general: {
title: "Geral",
browserData: {
+15
View File
@@ -1796,12 +1796,27 @@ export const ru: TranslationResources = {
projects: "Проекты",
connections: "Соединения",
agents: "Agents",
metadata: "Метаданные",
workspaces: "Workspaces",
providers: "Провайдеры",
usage: "Использование",
terminals: "Terminals",
host: "Обзор",
},
metadataGeneration: {
title: "Создание метаданных",
description:
"Выберите модель, которую Paseo использует для названий рабочих пространств, веток, сообщений коммитов и черновиков pull request",
selection: "Выбор модели",
automatic: "Автоматически",
preferred: "Вручную",
automaticHint: "Paseo выбирает быструю доступную модель",
preferredHint: "Выберите модель, которую использует Paseo",
model: "Модель",
fallbackHint: "Если она недоступна, Paseo использует другую доступную модель",
docs: "Документация",
saveError: "Не удалось обновить создание метаданных",
},
general: {
title: "Общий",
browserData: {
+14
View File
@@ -1742,12 +1742,26 @@ export const zhCN: TranslationResources = {
projects: "项目",
connections: "连接",
agents: "Agents",
metadata: "元数据",
workspaces: "Workspaces",
providers: "Providers",
usage: "使用情况",
terminals: "Terminals",
host: "概览",
},
metadataGeneration: {
title: "元数据生成",
description: "选择 Paseo 用于工作区标题、分支名称、提交消息和拉取请求草稿的模型",
selection: "模型选择",
automatic: "自动",
preferred: "手动",
automaticHint: "Paseo 会选择一个可用的快速模型",
preferredHint: "选择 Paseo 使用的模型",
model: "模型",
fallbackHint: "如果不可用,Paseo 会改用其他可用模型",
docs: "文档",
saveError: "无法更新元数据生成设置",
},
general: {
title: "通用",
browserData: {
@@ -36,6 +36,7 @@ import {
SquareTerminal,
Code2,
Smartphone,
Sparkles,
} from "lucide-react-native";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
@@ -107,6 +108,7 @@ import {
HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
@@ -184,6 +186,7 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "connections", labelKey: "settings.hostSections.connections", icon: Network },
{ id: "pair-device", labelKey: "openProject.tiles.pairDevice.title", icon: Smartphone },
{ id: "agents", labelKey: "settings.hostSections.agents", icon: Bot },
{ id: "metadata", labelKey: "settings.hostSections.metadata", icon: Sparkles },
{ id: "workspaces", labelKey: "settings.hostSections.workspaces", icon: FolderGit2 },
{ id: "providers", labelKey: "settings.hostSections.providers", icon: Boxes },
{ id: "usage", labelKey: "settings.hostSections.usage", icon: Gauge },
@@ -203,6 +206,8 @@ function renderHostSettingsContent(
return <HostPairDevicePage serverId={view.serverId} />;
case "agents":
return <HostAgentsPage serverId={view.serverId} />;
case "metadata":
return <MetadataGenerationPage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers":
@@ -0,0 +1,181 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation";
type SelectionMode = "automatic" | "preferred";
export function MetadataGenerationPage({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { config, isLoading: isConfigLoading, patchConfig } = useDaemonConfig(serverId);
const snapshot = useProvidersSnapshot(serverId);
const providers = useMemo(
() => buildSelectableProviderSelectorProviders(snapshot.entries),
[snapshot.entries],
);
const configuredProviders = config?.metadataGeneration.providers;
const configuredProvider = configuredProviders?.[0] ?? null;
const savedMode: SelectionMode = configuredProvider ? "preferred" : "automatic";
const [draftMode, setDraftMode] = useState<SelectionMode | null>(null);
const [isSaving, setIsSaving] = useState(false);
const mode = draftMode ?? savedMode;
useEffect(() => {
setDraftMode(null);
}, [configuredProvider?.model, configuredProvider?.provider]);
const modeOptions = useMemo(
() => [
{ value: "automatic" as const, label: t("settings.metadataGeneration.automatic") },
{ value: "preferred" as const, label: t("settings.metadataGeneration.preferred") },
],
[t],
);
const saveProviders = useCallback(
async (providersPatch: { provider: string; model?: string }[]) => {
setIsSaving(true);
try {
await patchConfig({ metadataGeneration: { providers: providersPatch } });
} catch (error) {
setDraftMode(null);
Alert.alert(
t("settings.metadataGeneration.saveError"),
error instanceof Error ? error.message : String(error),
);
} finally {
setIsSaving(false);
}
},
[patchConfig, t],
);
const handleModeChange = useCallback(
(next: SelectionMode) => {
setDraftMode(next);
if (next === "automatic") {
void saveProviders([]);
}
},
[saveProviders],
);
const handleModelSelect = useCallback(
(provider: AgentProvider, model: string) => {
setDraftMode("preferred");
void saveProviders([
{ provider, ...(model ? { model } : {}) },
...(configuredProviders?.slice(1) ?? []),
]);
},
[configuredProviders, saveProviders],
);
const handleSelectorOpen = useCallback(() => {
snapshot.refetchIfStale(configuredProvider?.provider);
}, [configuredProvider?.provider, snapshot]);
const handleRetryProvider = useCallback(
(provider: AgentProvider) => snapshot.refresh([provider]),
[snapshot],
);
const docsLink = useMemo(
() => (
<ExternalLink
href={METADATA_GENERATION_DOCS_URL}
label={t("settings.metadataGeneration.docs")}
/>
),
[t],
);
if (isConfigLoading || !config) {
return (
<View style={styles.loading}>
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
</View>
);
}
return (
<SettingsSection
title={t("settings.metadataGeneration.title")}
trailing={docsLink}
testID="metadata-generation-settings"
>
<Text style={styles.description}>{t("settings.metadataGeneration.description")}</Text>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.metadataGeneration.selection")}
</Text>
<Text style={settingsStyles.rowHint}>
{mode === "automatic"
? t("settings.metadataGeneration.automaticHint")
: t("settings.metadataGeneration.preferredHint")}
</Text>
</View>
<SegmentedControl
options={modeOptions}
value={mode}
onValueChange={handleModeChange}
size="sm"
testID="metadata-generation-mode"
/>
</View>
{mode === "preferred" ? (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.metadataGeneration.model")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.metadataGeneration.fallbackHint")}
</Text>
</View>
<CombinedModelSelector
providers={providers}
selectedProvider={configuredProvider?.provider ?? ""}
selectedModel={configuredProvider?.model ?? ""}
onSelect={handleModelSelect}
isLoading={snapshot.isLoading || snapshot.isFetching}
onOpen={handleSelectorOpen}
onRetryProvider={handleRetryProvider}
isRetryingProvider={snapshot.isRefreshing}
disabled={isSaving}
serverId={serverId}
desktopPlacement="bottom-start"
desktopMinWidth={360}
/>
</View>
) : null}
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: theme.fontSize.xs * 1.45,
marginHorizontal: theme.spacing[1],
},
loading: {
alignItems: "center",
justifyContent: "center",
minHeight: 180,
},
spinnerColor: {
color: theme.colors.foregroundMuted,
},
}));
@@ -248,6 +248,7 @@ describe("host settings section slugs", () => {
expect(normalizeHostSectionSlug("connections")).toBe("connections");
expect(normalizeHostSectionSlug("pair-device")).toBe("pair-device");
expect(normalizeHostSectionSlug("agents")).toBe("agents");
expect(normalizeHostSectionSlug("metadata")).toBe("metadata");
expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces");
expect(normalizeHostSectionSlug("projects")).toBe("projects");
expect(normalizeHostSectionSlug("providers")).toBe("providers");
+1
View File
@@ -512,6 +512,7 @@ export const HOST_SECTION_SLUGS = [
"connections",
"pair-device",
"agents",
"metadata",
"workspaces",
"providers",
"usage",
@@ -18,8 +18,17 @@ class ProviderSnapshots {
}
describe("resolveStructuredGenerationProviders", () => {
test("uses explicit configured provider models without refreshing provider snapshots", async () => {
const snapshots = new ProviderSnapshots([]);
test("tries the configured model before dynamically discovered fallbacks", async () => {
const snapshots = new ProviderSnapshots([
{
provider: "work-claude",
status: READY,
enabled: true,
models: [
{ provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true },
],
},
]);
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
@@ -31,8 +40,11 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]);
expect(snapshots.calls).toEqual([]);
expect(providers).toEqual([
{ provider: "mock", model: "ten-second-stream" },
{ provider: "work-claude", model: "claude-haiku-2026" },
]);
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
test("falls back to dynamic defaults and current selection when no provider is configured", async () => {
@@ -155,7 +167,7 @@ describe("resolveStructuredGenerationProviders", () => {
]);
});
test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => {
test("normalizes configured nested-provider aliases from the provider snapshot", async () => {
const snapshots = new ProviderSnapshots([
{
provider: "opencode",
@@ -186,8 +198,8 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]);
expect(snapshots.calls).toEqual([]);
expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]);
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
test("keeps explicit candidates when provider snapshots are in error state", async () => {
@@ -215,7 +227,10 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]);
expect(snapshots.calls).toEqual([]);
expect(providers).toEqual([
{ provider: "current-provider", model: "configured-model" },
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
]);
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
});
@@ -44,22 +44,6 @@ export async function resolveStructuredGenerationProviders(
options: ResolveStructuredGenerationProvidersOptions,
): Promise<StructuredGenerationProvider[]> {
const configuredProviders = readConfiguredProviders(options.daemonConfig);
if (configuredProviders.length > 0) {
const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders);
if (explicitProviders.length === configuredProviders.length) {
return dedupeProviders(explicitProviders);
}
const providerEntries = await options.providerSnapshotManager.listProviders({
cwd: options.cwd,
wait: false,
});
const providers = resolveConfiguredProviders(configuredProviders, providerEntries);
if (providers.length > 0) {
return dedupeProviders(providers);
}
}
const providerEntries = await options.providerSnapshotManager.listProviders({
cwd: options.cwd,
wait: true,
@@ -100,42 +84,6 @@ export async function resolveStructuredGenerationProviders(
return dedupeProviders(providers);
}
function resolveConfiguredProviders(
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
providerEntries: readonly ProviderSnapshotEntry[],
): StructuredGenerationProvider[] {
const enabledEntries = providerEntries.filter((entry) => entry.enabled);
const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0);
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
const providers: StructuredGenerationProvider[] = [];
for (const configured of configuredProviders) {
const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider);
if (resolved) {
providers.push(resolved);
}
}
return providers;
}
function resolveExplicitConfiguredProviders(
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
): StructuredGenerationProvider[] {
const providers: StructuredGenerationProvider[] = [];
for (const configured of configuredProviders) {
const provider = configured.provider.trim();
const model = configured.model?.trim();
if (!provider || !model) {
continue;
}
providers.push({
provider,
model,
...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}),
});
}
return providers;
}
function resolveCurrentSelection(
selection: ResolveStructuredGenerationProvidersOptions["currentSelection"],
readyEntries: readonly ProviderSnapshotEntry[],
+7 -3
View File
@@ -37,9 +37,13 @@ Each default is a match on the model id or name, so the first enabled provider t
The intent of the default order is to prefer small, fast, cheap models for these short tasks before falling back to whatever you have selected.
## Configuring the providers
## Choose a model
To control which models Paseo uses — for example to keep all metadata generation on one provider, or to prefer a local model — set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults.
Open **Settings → Host → Metadata** and select **Automatic** or **Manual**. In Manual mode, Paseo tries the model you choose first, then falls back to the remaining configured models and built-in candidates. Choosing a model replaces the first entry in `agents.metadataGeneration.providers` and preserves the rest of the list.
## Configure a custom fallback order
To configure more than one preferred model or control the exact order, set `agents.metadataGeneration.providers` in `~/.paseo/config.json`. Your entries are tried before the built-in defaults.
```json
{
@@ -60,7 +64,7 @@ Each entry accepts:
- `model` (optional) — a specific model id. Omit it to use that provider's default model.
- `thinkingOptionId` (optional) — a reasoning/thinking level for models that support one. Falls back to the model's default if the value isn't valid for that model.
Restart the daemon after editing the file.
The Settings screen replaces only the first entry and preserves the rest of a custom list. Restart the daemon after editing the file directly.
## Per-project instructions