feat: catalogue dynamique modèles Codex/Claude avec compatibilité CLI locale

Ajout du catalogue enrichi pour les modèles Codex et Claude avec:
- Compatibilité estimée avec la version CLI locale détectée
- Source d'origine (catalogue/Provider) pour chaque entrée
- Support du catalogue Provider API externe
- Matrice de compatibilité embarquée dans l'application

Frontend:
- UI de configuration des modèles avec affichage des états de compatibilité
- Suggestions dynamiques avec badges de compatibilité
- Messages d'aide contextuels (compatible/unknown/likelyTooRecent)
- Alertes non-bloquantes pour les modèles trop récents
- Gestion des échecs de catalogue avec saisie manuelle conservée

Backend:
- Ports CliVersionReader, ProviderModelCatalogue, CompatibilityMatrixSource
- Implémentations: ProcessCliVersionReader, HttpProviderModelCatalogue, EmbeddedCompatibilityMatrix
- Enrichissement des DTOs avec compatibility, cli_version, warnings
- Tests unitaires complets pour le resolver de catalogue
This commit is contained in:
2026-07-26 16:09:10 +02:00
parent e7bf1d3666
commit ca70ec75f4
25 changed files with 1582 additions and 167 deletions

View File

@ -42,7 +42,7 @@ import type {
ProjectWorkState,
ProjectSystemPermissions,
ProfileAvailability,
ProfileModelCatalogEntry,
ProfileModelCatalog,
ResolvedAgentSystemPermissions,
SystemPermissionSet,
Skill,
@ -74,6 +74,7 @@ import type {
} from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization";
import { normalizeTurnPage } from "../conversationNormalization";
import { normalizeProfileModelCatalog } from "../profileCatalog";
import type { HttpInvoker } from "./httpInvoker";
export class HttpProjectGateway implements ProjectGateway {
@ -183,11 +184,11 @@ export class HttpProfileGateway implements ProfileGateway {
request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model },
});
}
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
return this.http.invoke<ProfileModelCatalogEntry[]>("list_claude_models");
async listClaudeModels(): Promise<ProfileModelCatalog> {
return normalizeProfileModelCatalog(await this.http.invoke<unknown>("list_claude_models"));
}
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
return this.http.invoke<ProfileModelCatalogEntry[]>("list_codex_models");
async listCodexModels(): Promise<ProfileModelCatalog> {
return normalizeProfileModelCatalog(await this.http.invoke<unknown>("list_codex_models"));
}
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });

View File

@ -35,6 +35,7 @@ import type {
McpToolCatalogue,
McpToolPolicy,
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
ProfileModelCatalogEntry,
EffectivePermissions,
PairedDevice,
@ -1302,6 +1303,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
displayName: "Claude Sonnet 5",
aliases: ["sonnet"],
recommended: true,
compatibility: "compatible",
source: "catalogue",
},
{
adapter: "claude",
@ -1309,6 +1312,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
displayName: "Claude Opus 4.8",
aliases: ["opus"],
recommended: false,
compatibility: "unknown",
source: "catalogue",
},
{
adapter: "claude",
@ -1316,6 +1321,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
displayName: "Claude Haiku 4.5",
aliases: ["haiku"],
recommended: false,
compatibility: "likelyTooRecent",
source: "provider",
},
];
@ -1326,6 +1333,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
displayName: "GPT-5 Codex",
aliases: ["codex"],
recommended: true,
compatibility: "compatible",
source: "catalogue",
},
{
adapter: "codex",
@ -1333,6 +1342,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
displayName: "GPT-5",
aliases: ["general"],
recommended: false,
compatibility: "unknown",
source: "catalogue",
},
{
adapter: "codex",
@ -1340,6 +1351,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
displayName: "GPT-5 mini",
aliases: ["mini", "fast"],
recommended: false,
compatibility: "likelyTooRecent",
source: "provider",
},
];
@ -1420,12 +1433,20 @@ export class MockProfileGateway implements ProfileGateway {
return structuredClone(cloned);
}
async listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
return structuredClone(MOCK_CLAUDE_MODELS);
async listClaudeModels(): Promise<ProfileModelCatalog> {
return {
models: structuredClone(MOCK_CLAUDE_MODELS),
cliVersion: "2.1.220",
warnings: [],
};
}
async listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
return structuredClone(MOCK_CODEX_MODELS);
async listCodexModels(): Promise<ProfileModelCatalog> {
return {
models: structuredClone(MOCK_CODEX_MODELS),
cliVersion: "0.145.0",
warnings: ["Catalogue provider partiellement estime depuis les donnees locales."],
};
}
async cloneOpenCodeProfileFromSeed(

View File

@ -12,7 +12,7 @@ import type {
AgentProfile,
FirstRunState,
OpenCodeProviderCatalogEntry,
ProfileModelCatalogEntry,
ProfileModelCatalog,
ProfileAvailability,
} from "@/domain";
import type {
@ -21,6 +21,7 @@ import type {
ProfileGateway,
SaveOpenCodeProviderProfileInput,
} from "@/ports";
import { normalizeProfileModelCatalog } from "./profileCatalog";
export class TauriProfileGateway implements ProfileGateway {
firstRunState(): Promise<FirstRunState> {
@ -59,12 +60,12 @@ export class TauriProfileGateway implements ProfileGateway {
});
}
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
return invoke<ProfileModelCatalogEntry[]>("list_claude_models");
async listClaudeModels(): Promise<ProfileModelCatalog> {
return normalizeProfileModelCatalog(await invoke<unknown>("list_claude_models"));
}
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
return invoke<ProfileModelCatalogEntry[]>("list_codex_models");
async listCodexModels(): Promise<ProfileModelCatalog> {
return normalizeProfileModelCatalog(await invoke<unknown>("list_codex_models"));
}
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { normalizeProfileModelCatalog } from "./profileCatalog";
describe("normalizeProfileModelCatalog", () => {
it("wraps the legacy bare array response with unknown compatibility", () => {
expect(
normalizeProfileModelCatalog([
{
adapter: "codex",
modelId: "gpt-5-codex",
displayName: "GPT-5 Codex",
aliases: ["codex"],
recommended: true,
},
]),
).toEqual({
models: [
{
adapter: "codex",
modelId: "gpt-5-codex",
displayName: "GPT-5 Codex",
aliases: ["codex"],
recommended: true,
compatibility: "unknown",
source: "catalogue",
},
],
cliVersion: null,
warnings: [],
});
});
it("defaults missing enriched fields without dropping warnings", () => {
expect(
normalizeProfileModelCatalog({
models: [{ adapter: "claude", modelId: "claude-sonnet-5" }],
warnings: ["version inconnue"],
}),
).toEqual({
models: [
{
adapter: "claude",
modelId: "claude-sonnet-5",
displayName: "claude-sonnet-5",
aliases: [],
recommended: false,
compatibility: "unknown",
source: "catalogue",
},
],
cliVersion: null,
warnings: ["version inconnue"],
});
});
});

View File

@ -0,0 +1,63 @@
import type {
ModelCatalogSource,
ModelCompatibility,
ProfileModelCatalog,
ProfileModelCatalogEntry,
} from "@/domain";
type PartialCatalogEntry = Partial<ProfileModelCatalogEntry> & {
adapter?: "claude" | "codex";
modelId?: string;
displayName?: string;
};
function compatibilityOf(value: unknown): ModelCompatibility {
return value === "compatible" ||
value === "unknown" ||
value === "likelyTooRecent"
? value
: "unknown";
}
function sourceOf(value: unknown): ModelCatalogSource {
return value === "provider" ? "provider" : "catalogue";
}
function normalizeEntry(raw: PartialCatalogEntry): ProfileModelCatalogEntry {
const modelId = raw.modelId ?? "";
return {
adapter: raw.adapter ?? "codex",
modelId,
displayName: raw.displayName ?? modelId,
aliases: Array.isArray(raw.aliases) ? raw.aliases : [],
recommended: Boolean(raw.recommended),
compatibility: compatibilityOf(raw.compatibility),
source: sourceOf(raw.source),
};
}
export function normalizeProfileModelCatalog(raw: unknown): ProfileModelCatalog {
if (Array.isArray(raw)) {
return {
models: raw.map((entry) => normalizeEntry(entry as PartialCatalogEntry)),
cliVersion: null,
warnings: [],
};
}
const catalog =
raw && typeof raw === "object"
? (raw as Partial<ProfileModelCatalog>)
: {};
return {
models: Array.isArray(catalog.models)
? catalog.models.map((entry) => normalizeEntry(entry as PartialCatalogEntry))
: [],
cliVersion:
typeof catalog.cliVersion === "string" ? catalog.cliVersion : null,
warnings: Array.isArray(catalog.warnings)
? catalog.warnings.map(String)
: [],
};
}

View File

@ -1096,6 +1096,15 @@ export interface OpenCodeProviderCatalogEntry {
models: string[];
}
/** Estimated compatibility for a Codex/Claude model against the detected local CLI. */
export type ModelCompatibility =
| "compatible"
| "unknown"
| "likelyTooRecent";
/** Origin of a model catalogue entry. */
export type ModelCatalogSource = "catalogue" | "provider";
/** One searchable model from the Codex/Claude structured-profile catalogues. */
export interface ProfileModelCatalogEntry {
/** Structured adapter this model belongs to. */
@ -1108,6 +1117,19 @@ export interface ProfileModelCatalogEntry {
aliases: string[];
/** Whether this entry is the conservative default suggestion. */
recommended: boolean;
/** Best-effort compatibility estimate for the locally detected CLI version. */
compatibility: ModelCompatibility;
/** Whether the entry comes from IdeA's catalogue or a provider-derived source. */
source: ModelCatalogSource;
}
/** Enriched Codex/Claude model catalogue. Manual model entry remains supported. */
export interface ProfileModelCatalog {
models: ProfileModelCatalogEntry[];
/** Detected local CLI version, or null when unavailable. */
cliVersion: string | null;
/** Non-fatal catalogue/version diagnostics. */
warnings: string[];
}
/**

View File

@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import { DIProvider } from "@/app/di";
import { MockProfileGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import type { ProfileModelCatalogEntry } from "@/domain";
import type { ProfileModelCatalog } from "@/domain";
import { ProfilesSettings } from "./ProfilesSettings";
function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) {
@ -94,14 +94,14 @@ describe("ProfilesSettings", () => {
it("keeps manual model entry available when the catalogue fails", async () => {
class CatalogueDownProfileGateway extends MockProfileGateway {
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
listCodexModels(): Promise<ProfileModelCatalog> {
return Promise.reject(new Error("catalogue down"));
}
}
renderSettings(new CatalogueDownProfileGateway());
await waitReady();
expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy();
expect(await screen.findByText(/Catalogue provider indisponible/)).toBeTruthy();
await createProfile();
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
@ -109,5 +109,54 @@ describe("ProfilesSettings", () => {
) as HTMLInputElement;
fireEvent.change(model, { target: { value: "future-codex-model" } });
expect(model.value).toBe("future-codex-model");
expect(screen.getAllByText(/Catalogue provider indisponible/).length).toBeGreaterThan(0);
});
it("shows compatibility states in suggestions and contextual help", async () => {
renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
fireEvent.focus(model);
expect(await within(row).findByText("Compatible")).toBeTruthy();
expect(
within(row).getByText(
/Compatible avec Codex CLI 0\.145\.0 d'après le catalogue local IdeA\./,
),
).toBeTruthy();
fireEvent.change(model, { target: { value: "" } });
expect(within(row).getAllByText("Inconnu").length).toBeGreaterThan(0);
expect(within(row).getByText("Probablement trop récent")).toBeTruthy();
fireEvent.change(model, { target: { value: "future-codex-model" } });
expect(within(row).getByText("Inconnu")).toBeTruthy();
expect(
within(row).getByText(
/Compatibilité non connue pour Codex CLI 0\.145\.0 ; la saisie reste autorisée\./,
),
).toBeTruthy();
});
it("saves likely-too-recent models and shows a non-blocking warning", async () => {
const { profile } = renderSettings();
await waitReady();
await createProfile();
const row = screen.getAllByRole("listitem")[0];
const model = within(row).getByLabelText(/modele du profil/) as HTMLInputElement;
fireEvent.change(model, { target: { value: "gpt-5-mini" } });
fireEvent.click(within(row).getByRole("button", { name: "Enregistrer" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.some((p) => p.model === "gpt-5-mini")).toBe(true);
});
expect(
await screen.findByText(/Ce modèle semble plus récent que votre Codex CLI 0\.145\.0/),
).toBeTruthy();
});
});

View File

@ -8,12 +8,16 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import type {
AgentProfile,
GatewayError,
ModelCompatibility,
ProfileModelCatalog,
ProfileModelCatalogEntry,
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Input, Panel, cn } from "@/shared";
type ProfileTab = "codex" | "claude" | "openCode";
type ModelTab = "codex" | "claude";
type CatalogState = Record<ModelTab, ProfileModelCatalog & { unavailable: boolean }>;
const TABS: Array<{ id: ProfileTab; label: string }> = [
{ id: "codex", label: "Codex" },
@ -21,9 +25,16 @@ const TABS: Array<{ id: ProfileTab; label: string }> = [
{ id: "openCode", label: "OpenCode-local" },
];
const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = {
codex: [],
claude: [],
const EMPTY_MODEL_CATALOG: ProfileModelCatalog & { unavailable: boolean } = {
models: [],
cliVersion: null,
warnings: [],
unavailable: false,
};
const EMPTY_CATALOGUE: CatalogState = {
codex: EMPTY_MODEL_CATALOG,
claude: EMPTY_MODEL_CATALOG,
};
function describe(e: unknown): string {
@ -66,6 +77,185 @@ function optionLabel(entry: ProfileModelCatalogEntry): string {
: `${entry.displayName} (${entry.modelId})`;
}
function compatibilityLabel(compatibility: ModelCompatibility): string {
if (compatibility === "compatible") return "Compatible";
if (compatibility === "likelyTooRecent") return "Probablement trop récent";
return "Inconnu";
}
function engineLabel(tab: ModelTab): string {
return tab === "codex" ? "Codex CLI" : "Claude CLI";
}
function attentionBadge(
compatibility: ModelCompatibility,
cliVersion: string | null,
): string | null {
if (!cliVersion) return "CLI non détecté";
if (compatibility === "compatible") return null;
return compatibilityLabel(compatibility);
}
function catalogEntryFor(
model: string,
models: ProfileModelCatalogEntry[],
): ProfileModelCatalogEntry | null {
const normalized = model.trim().toLowerCase();
if (!normalized) return null;
return models.find((entry) => entry.modelId.toLowerCase() === normalized) ?? null;
}
function compatibilityFor(
model: string,
models: ProfileModelCatalogEntry[],
): ModelCompatibility {
return catalogEntryFor(model, models)?.compatibility ?? "unknown";
}
function modelHelp(
tab: ModelTab,
model: string,
catalog: ProfileModelCatalog & { unavailable: boolean },
): string {
const cli = engineLabel(tab);
if (catalog.unavailable) {
return "Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.";
}
if (!catalog.cliVersion) {
return `Version du ${cli} non détectée ; IdeA ne peut pas estimer la compatibilité.`;
}
const compatibility = compatibilityFor(model, catalog.models);
if (compatibility === "compatible") {
return `Compatible avec ${cli} ${catalog.cliVersion} d'après le catalogue local IdeA.`;
}
if (compatibility === "likelyTooRecent") {
return `Probablement trop récent pour ${cli} ${catalog.cliVersion} ; mettez à jour le CLI si le lancement échoue.`;
}
return `Compatibilité non connue pour ${cli} ${catalog.cliVersion} ; la saisie reste autorisée.`;
}
function saveWarningText(tab: ModelTab, cliVersion: string | null): string {
const cli = engineLabel(tab);
const version = cliVersion ? ` ${cliVersion}` : "";
return `Ce modèle semble plus récent que votre ${cli}${version}. Le profil peut être enregistré, mais l'agent pourrait échouer au lancement tant que le CLI n'est pas mis à jour.`;
}
function matchingSuggestions(
model: string,
models: ProfileModelCatalogEntry[],
): ProfileModelCatalogEntry[] {
const q = model.trim().toLowerCase();
const filtered = q
? models.filter((entry) =>
[entry.modelId, entry.displayName, ...entry.aliases]
.join(" ")
.toLowerCase()
.includes(q),
)
: models;
return filtered.slice(0, 5);
}
function ModelField({
profileId,
profileName,
tab,
model,
catalog,
onChange,
}: {
profileId: string;
profileName: string;
tab: ModelTab;
model: string;
catalog: ProfileModelCatalog & { unavailable: boolean };
onChange: (model: string) => void;
}) {
const [focused, setFocused] = useState(false);
const inputId = `profile-model-${tab}-${profileId}`;
const compatibility = compatibilityFor(model, catalog.models);
const badge = attentionBadge(compatibility, catalog.cliVersion);
const suggestions = matchingSuggestions(model, catalog.models);
return (
<div className="flex min-w-0 flex-col gap-1">
<label
htmlFor={inputId}
className="flex items-center gap-2 text-xs font-medium text-muted"
>
Modèle
<span
className="text-faint"
title="IdeA estime la compatibilité à partir du catalogue maintenu dans l'application et de la version du CLI détectée localement. Le provider peut accepter ou refuser le modèle différemment au moment du lancement."
>
compatibilité estimée
</span>
{badge && (
<span
className={cn(
"rounded-full px-2 py-0.5 text-[11px] font-medium",
compatibility === "likelyTooRecent"
? "bg-warning/15 text-warning"
: "bg-raised text-muted",
)}
>
{badge}
</span>
)}
</label>
<Input
id={inputId}
aria-label={`modele du profil ${profileName}`}
placeholder={
catalog.models.length > 0
? "Choisir ou saisir un modèle"
: "Saisir un modèle"
}
value={model}
onFocus={() => setFocused(true)}
onBlur={() => window.setTimeout(() => setFocused(false), 120)}
onChange={(e) => onChange(e.target.value)}
/>
<small className="text-xs text-faint">{modelHelp(tab, model, catalog)}</small>
{focused && suggestions.length > 0 && (
<div
role="listbox"
aria-label={`suggestions de modèles ${profileName}`}
className="mt-1 max-h-36 overflow-auto rounded-md border border-border bg-raised p-1"
>
{suggestions.map((entry) => (
<button
key={entry.modelId}
type="button"
role="option"
className="flex w-full items-center justify-between gap-3 rounded px-2 py-1.5 text-left text-xs hover:bg-surface"
onMouseDown={(e) => {
e.preventDefault();
onChange(entry.modelId);
setFocused(false);
}}
>
<span className="min-w-0 truncate">{optionLabel(entry)}</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium",
entry.compatibility === "compatible"
? "bg-primary/10 text-primary"
: entry.compatibility === "likelyTooRecent"
? "bg-warning/15 text-warning"
: "bg-surface text-muted",
)}
>
{compatibilityLabel(entry.compatibility)}
</span>
</button>
))}
</div>
)}
</div>
);
}
export function ProfilesSettings() {
const { profile } = useGateways();
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
@ -75,6 +265,7 @@ export function ProfilesSettings() {
const [drafts, setDrafts] = useState<Record<string, AgentProfile>>({});
const [error, setError] = useState<string | null>(null);
const [catalogueWarning, setCatalogueWarning] = useState<string | null>(null);
const [saveWarnings, setSaveWarnings] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
@ -106,12 +297,18 @@ export function ProfilesSettings() {
]);
if (cancelled) return;
setCatalogue({
codex: codex.status === "fulfilled" ? codex.value : [],
claude: claude.status === "fulfilled" ? claude.value : [],
codex:
codex.status === "fulfilled"
? { ...codex.value, unavailable: false }
: { ...EMPTY_MODEL_CATALOG, unavailable: true },
claude:
claude.status === "fulfilled"
? { ...claude.value, unavailable: false }
: { ...EMPTY_MODEL_CATALOG, unavailable: true },
});
if (codex.status === "rejected" || claude.status === "rejected") {
setCatalogueWarning(
"Catalogue de modeles indisponible: saisie manuelle active.",
"Catalogue provider indisponible ; vous pouvez saisir le modèle manuellement.",
);
}
}
@ -146,7 +343,7 @@ export function ProfilesSettings() {
try {
const models =
activeTab === "codex" || activeTab === "claude"
? catalogue[activeTab]
? catalogue[activeTab].models
: [];
const recommended = models.find((m) => m.recommended)?.modelId;
await profile.cloneProfileFromSeed({
@ -167,9 +364,24 @@ export function ProfilesSettings() {
if (!draft) return;
setBusy(true);
setError(null);
setSaveWarnings((prev) => {
const { [id]: _ignored, ...rest } = prev;
return rest;
});
try {
const tab = tabFor(draft);
const warning =
tab === "codex" || tab === "claude"
? compatibilityFor(modelOf(draft), catalogue[tab].models) ===
"likelyTooRecent"
? saveWarningText(tab, catalogue[tab].cliVersion)
: null
: null;
await profile.saveProfile(draft);
await refresh();
if (warning) {
setSaveWarnings((prev) => ({ ...prev, [id]: warning }));
}
} catch (e) {
setError(describe(e));
} finally {
@ -212,7 +424,9 @@ export function ProfilesSettings() {
}
const modelOptions =
activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : [];
activeTab === "codex" || activeTab === "claude"
? catalogue[activeTab].models
: [];
return (
<Panel
@ -258,13 +472,12 @@ export function ProfilesSettings() {
<p className="text-xs text-muted">{catalogueWarning}</p>
)}
<datalist id={`profile-models-${activeTab}`}>
{modelOptions.map((entry) => (
<option key={entry.modelId} value={entry.modelId}>
{optionLabel(entry)}
</option>
{(activeTab === "codex" || activeTab === "claude") &&
catalogue[activeTab].warnings.map((warning) => (
<p key={warning} className="text-xs text-warning">
{warning}
</p>
))}
</datalist>
{visibleProfiles.length === 0 ? (
<p className="text-sm text-muted">
@ -296,25 +509,42 @@ export function ProfilesSettings() {
/>
</label>
<label className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-medium text-muted">Modele</span>
<Input
aria-label={`modele du profil ${saved.name}`}
list={`profile-models-${activeTab}`}
placeholder={
modelOptions.length > 0
? "Choisir ou saisir un modele"
: "Saisir un modele"
}
value={model}
onChange={(e) =>
updateDraft(saved.id, (p) =>
withModel(p, e.target.value),
)
{activeTab === "codex" || activeTab === "claude" ? (
<ModelField
profileId={saved.id}
profileName={saved.name}
tab={activeTab}
model={model}
catalog={catalogue[activeTab]}
onChange={(value) =>
updateDraft(saved.id, (p) => withModel(p, value))
}
/>
</label>
) : (
<label className="flex min-w-0 flex-col gap-1">
<span className="text-xs font-medium text-muted">Modèle</span>
<Input
aria-label={`modele du profil ${saved.name}`}
placeholder={
modelOptions.length > 0
? "Choisir ou saisir un modèle"
: "Saisir un modèle"
}
value={model}
onChange={(e) =>
updateDraft(saved.id, (p) =>
withModel(p, e.target.value),
)
}
/>
</label>
)}
</div>
{saveWarnings[saved.id] && (
<p className="mt-2 rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{saveWarnings[saved.id]}
</p>
)}
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<code className="min-w-0 truncate text-xs text-muted">

View File

@ -37,7 +37,7 @@ import type {
McpToolPolicy,
OpenCodeConfig,
OpenCodeProviderCatalogEntry,
ProfileModelCatalogEntry,
ProfileModelCatalog,
EffectivePermissions,
PairedDevice,
PairingCode,
@ -670,10 +670,10 @@ export interface ProfileGateway {
* Used by Settings duplication for Codex/Claude/OpenCode identity copies.
*/
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile>;
/** Curated Claude Code model catalogue. Manual model entry remains supported. */
listClaudeModels(): Promise<ProfileModelCatalogEntry[]>;
/** Curated Codex CLI model catalogue. Manual model entry remains supported. */
listCodexModels(): Promise<ProfileModelCatalogEntry[]>;
/** Enriched Claude Code model catalogue. Manual model entry remains supported. */
listClaudeModels(): Promise<ProfileModelCatalog>;
/** Enriched Codex CLI model catalogue. Manual model entry remains supported. */
listCodexModels(): Promise<ProfileModelCatalog>;
/** Persists the batch of chosen profiles, closing the first run. */
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
/**