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)
: [],
};
}