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[],