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:
@ -71,3 +71,4 @@
|
|||||||
- [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA.
|
- [ticket103-network-permission-ux-surface](ticket103-network-permission-ux-surface.md) — Stable UX convention for agent network permissions in IdeA.
|
||||||
- [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix
|
- [codex-network-access-config-fix](codex-network-access-config-fix.md) — memory note codex-network-access-config-fix
|
||||||
- [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping
|
- [multi-profile-codex-claude-model-catalogue-scoping](multi-profile-codex-claude-model-catalogue-scoping.md) — memory note multi-profile-codex-claude-model-catalogue-scoping
|
||||||
|
- [model-catalogue-compat-cadrage](model-catalogue-compat-cadrage.md) — Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude.
|
||||||
|
|||||||
25
.ideai/memory/model-catalogue-compat-cadrage.md
Normal file
25
.ideai/memory/model-catalogue-compat-cadrage.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: model-catalogue-compat-cadrage
|
||||||
|
description: Frontières hexagonales, ports, DTO, fallback et matrice de compatibilité versionnée pour l'évolution du catalogue de modèles des profils structurés Codex/Claude.
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
Évolution de `ListClaudeModels`/`ListCodexModels` (catalogue statique `application/src/agent/model_catalogue.rs`) vers un catalogue enrichi par compatibilité CLI.
|
||||||
|
|
||||||
|
**Décisions tranchées :**
|
||||||
|
- JAMAIS scraper les TUI `/model`, JAMAIS exécuter les CLIs pour énumérer les modèles. Seule exécution CLI autorisée : `<cli> --version` (pattern existant `infrastructure/runtime::detection_spec` + port `ProcessSpawner`). Saisie libre toujours ouverte.
|
||||||
|
- 3 sources non bloquantes à dégradation indépendante : API provider `/v1/models` (best-effort, uniquement si clé env/SecretStore présente, sinon skip), catalogue statique seed, matrice de compat.
|
||||||
|
|
||||||
|
**Hexagonal :**
|
||||||
|
- Domaine (pur) : VO `CliVersion` (Ord), enum `ModelCompatibility {Compatible|Unknown|LikelyTooRecent}` (miroir des 3 états produit), VO `CompatibilityMatrix` (forme seule), fonction pure `evaluate_compatibility(matrix, adapter, model_id, Option<CliVersion>)` — version None ⇒ Unknown, modèle absent ⇒ Unknown, min<=ver ⇒ Compatible, min>ver ⇒ LikelyTooRecent.
|
||||||
|
- Nouveaux ports : `CliVersionReader`, `ProviderModelCatalogue` (Ok(vec![]) si pas de clé), `CompatibilityMatrixSource` (infaillible).
|
||||||
|
- Application : use case unique `ResolveModelCatalogue{adapter}` async, jamais de hard-error sur échec source (warnings + fallback). `ListClaude/CodexModels` deviennent des façades.
|
||||||
|
- Infra : `ProcessCliVersionReader`, `HttpProviderModelCatalogue` (reqwest), `EmbeddedCompatibilityMatrix`.
|
||||||
|
|
||||||
|
**Matrice de compat = DONNÉE, pas code** : JSON versionné maintenu dans IdeA, bundlé via `include_str!` (seed infaillible) + override optionnel `app_data_dir/IdeA/model-compat.json`. Ajouter un modèle = éditer le JSON, zéro code (Open/Closed).
|
||||||
|
|
||||||
|
**DTO (rupture front)** : `ProfileModelCatalogDto` passe de `transparent Vec` à `{ models:[{...,compatibility,source}], cliVersion:string|null, warnings:string[] }`. Répercuter ports TS + 2 adapters + mock + ProfilesSettings.
|
||||||
|
|
||||||
|
**Découpage** : B1 domaine pur, B2 use case ports mockés, B3 infra ; F1 contrat, F2 ProfilesSettings 3 badges. UX passe avant F2 (libellés des 3 états, warnings, cliVersion null).
|
||||||
|
|
||||||
|
**Point ouvert produit** : récup clé provider — proposé best-effort sur clé env/SecretStore existante, pas de prompt dédié.
|
||||||
@ -1189,20 +1189,20 @@ pub async fn list_opencode_providers(
|
|||||||
Ok(state.list_opencode_providers.execute().into())
|
Ok(state.list_opencode_providers.execute().into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `list_claude_models` — static curated Claude model catalogue.
|
/// `list_claude_models` — enriched Claude model catalogue.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_claude_models(
|
pub async fn list_claude_models(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<ProfileModelCatalogDto, ErrorDto> {
|
) -> Result<ProfileModelCatalogDto, ErrorDto> {
|
||||||
Ok(state.list_claude_models.execute().into())
|
Ok(state.list_claude_models.execute().await.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `list_codex_models` — static curated Codex model catalogue.
|
/// `list_codex_models` — enriched Codex model catalogue.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_codex_models(
|
pub async fn list_codex_models(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<ProfileModelCatalogDto, ErrorDto> {
|
) -> Result<ProfileModelCatalogDto, ErrorDto> {
|
||||||
Ok(state.list_codex_models.execute().into())
|
Ok(state.list_codex_models.execute().await.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `save_opencode_provider_profile` — create or replace an OpenCode profile
|
/// `save_opencode_provider_profile` — create or replace an OpenCode profile
|
||||||
|
|||||||
@ -141,21 +141,30 @@ fn clone_profile_from_seed_request_deserialises_camelcase_overrides() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() {
|
fn profile_model_catalogue_dto_serialises_searchable_camelcase_entries() {
|
||||||
let dto = ProfileModelCatalogDto(vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto {
|
let dto = ProfileModelCatalogDto {
|
||||||
adapter: StructuredAdapter::Codex,
|
models: vec![app_tauri_lib::dto::ProfileModelCatalogEntryDto {
|
||||||
model_id: "gpt-5-codex".to_owned(),
|
adapter: StructuredAdapter::Codex,
|
||||||
display_name: "GPT-5 Codex".to_owned(),
|
model_id: "gpt-5-codex".to_owned(),
|
||||||
aliases: vec!["codex".to_owned()],
|
display_name: "GPT-5 Codex".to_owned(),
|
||||||
recommended: true,
|
aliases: vec!["codex".to_owned()],
|
||||||
}]);
|
recommended: true,
|
||||||
|
compatibility: domain::ModelCompatibility::Compatible,
|
||||||
|
source: domain::ModelCatalogSource::Catalogue,
|
||||||
|
}],
|
||||||
|
cli_version: Some("0.45.1".to_owned()),
|
||||||
|
warnings: vec!["provider unavailable".to_owned()],
|
||||||
|
};
|
||||||
|
|
||||||
let value = serde_json::to_value(&dto).unwrap();
|
let value = serde_json::to_value(&dto).unwrap();
|
||||||
let arr = value.as_array().expect("transparent array");
|
assert_eq!(value["cliVersion"], "0.45.1");
|
||||||
assert_eq!(arr[0]["adapter"], "codex");
|
assert_eq!(value["warnings"], json!(["provider unavailable"]));
|
||||||
assert_eq!(arr[0]["modelId"], "gpt-5-codex");
|
assert_eq!(value["models"][0]["adapter"], "codex");
|
||||||
assert_eq!(arr[0]["displayName"], "GPT-5 Codex");
|
assert_eq!(value["models"][0]["modelId"], "gpt-5-codex");
|
||||||
assert_eq!(arr[0]["aliases"], json!(["codex"]));
|
assert_eq!(value["models"][0]["displayName"], "GPT-5 Codex");
|
||||||
assert_eq!(arr[0]["recommended"], true);
|
assert_eq!(value["models"][0]["aliases"], json!(["codex"]));
|
||||||
|
assert_eq!(value["models"][0]["recommended"], true);
|
||||||
|
assert_eq!(value["models"][0]["compatibility"], "compatible");
|
||||||
|
assert_eq!(value["models"][0]["source"], "catalogue");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
//! Static curated model catalogues for structured Claude/Codex profiles.
|
//! Curated and best-effort model catalogues for structured Claude/Codex profiles.
|
||||||
//!
|
//!
|
||||||
//! The CLIs do not expose a stable machine-readable model catalogue. These lists
|
//! The CLIs do not expose a stable machine-readable model catalogue and must not
|
||||||
//! are therefore intentionally small, static and infallible; the UI must still
|
//! be asked to enumerate models. The only local probe allowed here is
|
||||||
//! keep manual entry as a fallback for models not listed here.
|
//! `<cli> --version`; provider APIs are optional and failures degrade to the
|
||||||
|
//! static seed plus warnings.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::model_catalogue::{evaluate_compatibility, CliVersion};
|
||||||
|
use domain::ports::{CliVersionReader, CompatibilityMatrixSource, ProviderModelCatalogue};
|
||||||
use domain::profile::StructuredAdapter;
|
use domain::profile::StructuredAdapter;
|
||||||
|
use domain::{ModelCatalogSource, ModelCompatibility};
|
||||||
|
|
||||||
/// One searchable model entry for a structured profile adapter.
|
/// One searchable model entry for a structured profile adapter.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@ -19,6 +26,10 @@ pub struct ProfileModelCatalogEntry {
|
|||||||
pub aliases: Vec<String>,
|
pub aliases: Vec<String>,
|
||||||
/// Whether this entry is the conservative default suggestion.
|
/// Whether this entry is the conservative default suggestion.
|
||||||
pub recommended: bool,
|
pub recommended: bool,
|
||||||
|
/// Compatibility state against the locally detected CLI version.
|
||||||
|
pub compatibility: ModelCompatibility,
|
||||||
|
/// Source that contributed the model entry.
|
||||||
|
pub source: ModelCatalogSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entry(
|
fn entry(
|
||||||
@ -34,6 +45,20 @@ fn entry(
|
|||||||
display_name: display_name.to_owned(),
|
display_name: display_name.to_owned(),
|
||||||
aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
|
aliases: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
|
||||||
recommended,
|
recommended,
|
||||||
|
compatibility: ModelCompatibility::Unknown,
|
||||||
|
source: ModelCatalogSource::Catalogue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_entry(adapter: StructuredAdapter, model_id: String) -> ProfileModelCatalogEntry {
|
||||||
|
ProfileModelCatalogEntry {
|
||||||
|
adapter,
|
||||||
|
display_name: model_id.clone(),
|
||||||
|
model_id,
|
||||||
|
aliases: Vec::new(),
|
||||||
|
recommended: false,
|
||||||
|
compatibility: ModelCompatibility::Unknown,
|
||||||
|
source: ModelCatalogSource::Provider,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,73 +118,254 @@ pub fn codex_model_catalogue() -> Vec<ProfileModelCatalogEntry> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Use case exposing the static Claude model catalogue.
|
/// Output of structured model-catalogue resolution.
|
||||||
pub struct ListClaudeModels;
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListModelsOutput {
|
||||||
|
/// The catalogue entries.
|
||||||
|
pub models: Vec<ProfileModelCatalogEntry>,
|
||||||
|
/// Best-effort local CLI version.
|
||||||
|
pub cli_version: Option<CliVersion>,
|
||||||
|
/// Non-fatal fallback/degradation warnings.
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Output of [`ListClaudeModels::execute`].
|
/// Output of [`ListClaudeModels::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ListClaudeModelsOutput {
|
pub struct ListClaudeModelsOutput {
|
||||||
/// The catalogue entries.
|
/// The catalogue entries.
|
||||||
pub models: Vec<ProfileModelCatalogEntry>,
|
pub models: Vec<ProfileModelCatalogEntry>,
|
||||||
|
/// Best-effort local CLI version.
|
||||||
|
pub cli_version: Option<CliVersion>,
|
||||||
|
/// Non-fatal fallback/degradation warnings.
|
||||||
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListClaudeModels {
|
impl From<ListModelsOutput> for ListClaudeModelsOutput {
|
||||||
/// Builds the use case (stateless, no ports to inject).
|
fn from(out: ListModelsOutput) -> Self {
|
||||||
#[must_use]
|
Self {
|
||||||
pub const fn new() -> Self {
|
models: out.models,
|
||||||
Self
|
cli_version: out.cli_version,
|
||||||
}
|
warnings: out.warnings,
|
||||||
|
|
||||||
/// Lists curated Claude models. Infallible.
|
|
||||||
#[must_use]
|
|
||||||
pub fn execute(&self) -> ListClaudeModelsOutput {
|
|
||||||
ListClaudeModelsOutput {
|
|
||||||
models: claude_model_catalogue(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ListClaudeModels {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Use case exposing the static Codex model catalogue.
|
|
||||||
pub struct ListCodexModels;
|
|
||||||
|
|
||||||
/// Output of [`ListCodexModels::execute`].
|
/// Output of [`ListCodexModels::execute`].
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ListCodexModelsOutput {
|
pub struct ListCodexModelsOutput {
|
||||||
/// The catalogue entries.
|
/// The catalogue entries.
|
||||||
pub models: Vec<ProfileModelCatalogEntry>,
|
pub models: Vec<ProfileModelCatalogEntry>,
|
||||||
|
/// Best-effort local CLI version.
|
||||||
|
pub cli_version: Option<CliVersion>,
|
||||||
|
/// Non-fatal fallback/degradation warnings.
|
||||||
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListCodexModels {
|
impl From<ListModelsOutput> for ListCodexModelsOutput {
|
||||||
/// Builds the use case (stateless, no ports to inject).
|
fn from(out: ListModelsOutput) -> Self {
|
||||||
#[must_use]
|
Self {
|
||||||
pub const fn new() -> Self {
|
models: out.models,
|
||||||
Self
|
cli_version: out.cli_version,
|
||||||
}
|
warnings: out.warnings,
|
||||||
|
|
||||||
/// Lists curated Codex models. Infallible.
|
|
||||||
#[must_use]
|
|
||||||
pub fn execute(&self) -> ListCodexModelsOutput {
|
|
||||||
ListCodexModelsOutput {
|
|
||||||
models: codex_model_catalogue(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ListCodexModels {
|
/// Use case resolving an enriched structured model catalogue.
|
||||||
fn default() -> Self {
|
pub struct ResolveModelCatalogue {
|
||||||
Self::new()
|
cli_versions: Arc<dyn CliVersionReader>,
|
||||||
|
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||||
|
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolveModelCatalogue {
|
||||||
|
/// Builds the use case from hexagonal ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
cli_versions: Arc<dyn CliVersionReader>,
|
||||||
|
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||||
|
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
cli_versions,
|
||||||
|
provider_catalogue,
|
||||||
|
matrix_source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves models for one structured adapter. Infallible by design.
|
||||||
|
pub async fn execute(&self, adapter: StructuredAdapter) -> ListModelsOutput {
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
let (matrix, matrix_warnings) = self.matrix_source.compatibility_matrix();
|
||||||
|
warnings.extend(matrix_warnings);
|
||||||
|
|
||||||
|
let cli_version = match self.cli_versions.read_cli_version(adapter).await {
|
||||||
|
Ok(version) => version,
|
||||||
|
Err(warning) => {
|
||||||
|
warnings.push(warning);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut models = match adapter {
|
||||||
|
StructuredAdapter::Claude => claude_model_catalogue(),
|
||||||
|
StructuredAdapter::Codex => codex_model_catalogue(),
|
||||||
|
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.provider_catalogue.list_provider_models(adapter).await {
|
||||||
|
Ok(provider_models) => {
|
||||||
|
let existing = models
|
||||||
|
.iter()
|
||||||
|
.map(|model| model.model_id.clone())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
models.extend(
|
||||||
|
provider_models
|
||||||
|
.into_iter()
|
||||||
|
.filter(|model_id| !existing.contains(model_id))
|
||||||
|
.map(|model_id| provider_entry(adapter, model_id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(warning) => warnings.push(warning),
|
||||||
|
}
|
||||||
|
|
||||||
|
for model in &mut models {
|
||||||
|
model.compatibility =
|
||||||
|
evaluate_compatibility(&matrix, adapter, &model.model_id, cli_version.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
models.sort_by(|a, b| {
|
||||||
|
b.recommended
|
||||||
|
.cmp(&a.recommended)
|
||||||
|
.then_with(|| a.display_name.cmp(&b.display_name))
|
||||||
|
.then_with(|| a.model_id.cmp(&b.model_id))
|
||||||
|
});
|
||||||
|
|
||||||
|
ListModelsOutput {
|
||||||
|
models,
|
||||||
|
cli_version,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use case exposing the Claude model catalogue.
|
||||||
|
pub struct ListClaudeModels {
|
||||||
|
resolver: ResolveModelCatalogue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListClaudeModels {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
cli_versions: Arc<dyn CliVersionReader>,
|
||||||
|
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||||
|
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists Claude models. Infallible.
|
||||||
|
pub async fn execute(&self) -> ListClaudeModelsOutput {
|
||||||
|
self.resolver
|
||||||
|
.execute(StructuredAdapter::Claude)
|
||||||
|
.await
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use case exposing the Codex model catalogue.
|
||||||
|
pub struct ListCodexModels {
|
||||||
|
resolver: ResolveModelCatalogue,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListCodexModels {
|
||||||
|
/// Builds the use case.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
cli_versions: Arc<dyn CliVersionReader>,
|
||||||
|
provider_catalogue: Arc<dyn ProviderModelCatalogue>,
|
||||||
|
matrix_source: Arc<dyn CompatibilityMatrixSource>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
resolver: ResolveModelCatalogue::new(cli_versions, provider_catalogue, matrix_source),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists Codex models. Infallible.
|
||||||
|
pub async fn execute(&self) -> ListCodexModelsOutput {
|
||||||
|
self.resolver.execute(StructuredAdapter::Codex).await.into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{CliVersion, CompatibilityMatrix};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
struct FakeCliVersionReader(Option<Result<Option<CliVersion>, String>>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CliVersionReader for FakeCliVersionReader {
|
||||||
|
async fn read_cli_version(
|
||||||
|
&self,
|
||||||
|
_adapter: StructuredAdapter,
|
||||||
|
) -> Result<Option<CliVersion>, String> {
|
||||||
|
self.0
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| Ok(Some(CliVersion::parse("1.0.0").unwrap())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeProvider(Vec<String>, Option<String>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderModelCatalogue for FakeProvider {
|
||||||
|
async fn list_provider_models(
|
||||||
|
&self,
|
||||||
|
_adapter: StructuredAdapter,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
if let Some(warning) = &self.1 {
|
||||||
|
Err(warning.clone())
|
||||||
|
} else {
|
||||||
|
Ok(self.0.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeMatrixSource(CompatibilityMatrix, Vec<String>);
|
||||||
|
|
||||||
|
impl CompatibilityMatrixSource for FakeMatrixSource {
|
||||||
|
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
|
||||||
|
(self.0.clone(), self.1.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolver(
|
||||||
|
version: Option<Result<Option<CliVersion>, String>>,
|
||||||
|
provider: Vec<String>,
|
||||||
|
provider_warning: Option<String>,
|
||||||
|
) -> ResolveModelCatalogue {
|
||||||
|
ResolveModelCatalogue::new(
|
||||||
|
Arc::new(FakeCliVersionReader(version)),
|
||||||
|
Arc::new(FakeProvider(provider, provider_warning)),
|
||||||
|
Arc::new(FakeMatrixSource(
|
||||||
|
CompatibilityMatrix {
|
||||||
|
version: 1,
|
||||||
|
claude: HashMap::from([
|
||||||
|
("claude-sonnet-5".to_owned(), "1.0.0".to_owned()),
|
||||||
|
("claude-opus-4-8".to_owned(), "2.0.0".to_owned()),
|
||||||
|
]),
|
||||||
|
codex: HashMap::from([("gpt-5-codex".to_owned(), "1.0.0".to_owned())]),
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn static_catalogues_are_non_empty_searchable_and_have_one_default() {
|
fn static_catalogues_are_non_empty_searchable_and_have_one_default() {
|
||||||
@ -177,7 +383,86 @@ mod tests {
|
|||||||
assert_eq!(model.adapter, adapter);
|
assert_eq!(model.adapter, adapter);
|
||||||
assert!(!model.model_id.trim().is_empty());
|
assert!(!model.model_id.trim().is_empty());
|
||||||
assert!(!model.display_name.trim().is_empty());
|
assert!(!model.display_name.trim().is_empty());
|
||||||
|
assert_eq!(model.compatibility, ModelCompatibility::Unknown);
|
||||||
|
assert_eq!(model.source, ModelCatalogSource::Catalogue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolver_enriches_static_catalogue_with_cli_compatibility() {
|
||||||
|
let out = resolver(
|
||||||
|
Some(Ok(Some(CliVersion::parse("1.0.0").unwrap()))),
|
||||||
|
vec![],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.execute(StructuredAdapter::Claude)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let sonnet = out
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.model_id == "claude-sonnet-5")
|
||||||
|
.unwrap();
|
||||||
|
let opus = out
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.model_id == "claude-opus-4-8")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sonnet.compatibility, ModelCompatibility::Compatible);
|
||||||
|
assert_eq!(opus.compatibility, ModelCompatibility::LikelyTooRecent);
|
||||||
|
assert_eq!(out.cli_version.unwrap().raw, "1.0.0");
|
||||||
|
assert!(out.warnings.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolver_keeps_provider_and_cli_failures_non_blocking() {
|
||||||
|
let out = resolver(
|
||||||
|
Some(Err("codex version unavailable".to_owned())),
|
||||||
|
vec![],
|
||||||
|
Some("provider unavailable".to_owned()),
|
||||||
|
)
|
||||||
|
.execute(StructuredAdapter::Codex)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(!out.models.is_empty());
|
||||||
|
assert_eq!(out.cli_version, None);
|
||||||
|
assert_eq!(
|
||||||
|
out.warnings,
|
||||||
|
vec![
|
||||||
|
"codex version unavailable".to_owned(),
|
||||||
|
"provider unavailable".to_owned()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(out
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.all(|model| model.compatibility == ModelCompatibility::Unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolver_adds_provider_only_models_without_duplicate_seed_entries() {
|
||||||
|
let out = resolver(
|
||||||
|
None,
|
||||||
|
vec!["gpt-5-codex".to_owned(), "gpt-5-provider".to_owned()],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.execute(StructuredAdapter::Codex)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
out.models
|
||||||
|
.iter()
|
||||||
|
.filter(|model| model.model_id == "gpt-5-codex")
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let provider = out
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.find(|model| model.model_id == "gpt-5-provider")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(provider.source, ModelCatalogSource::Provider);
|
||||||
|
assert_eq!(provider.compatibility, ModelCompatibility::Unknown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,11 +40,11 @@ pub mod window;
|
|||||||
pub mod workstate;
|
pub mod workstate;
|
||||||
|
|
||||||
pub use agent::{
|
pub use agent::{
|
||||||
drain_reply_stream_with_readiness, drain_with_readiness,
|
claude_model_catalogue, codex_model_catalogue, drain_reply_stream_with_readiness,
|
||||||
drain_with_readiness_and_announcements, drain_with_readiness_outcome, reference_profile_id,
|
drain_with_readiness, drain_with_readiness_and_announcements, drain_with_readiness_outcome,
|
||||||
reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer,
|
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
|
||||||
AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput,
|
AgentResumer, AnnouncementPublisher, ChangeAgentProfile, ChangeAgentProfileInput,
|
||||||
CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
ChangeAgentProfileOutput, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||||
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
CloneOpenCodeProfileFromSeedOutput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
||||||
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
|
CloneProfileFromSeedOutput, ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput,
|
||||||
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||||
|
|||||||
@ -24,12 +24,12 @@ use domain::profile::{
|
|||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
reference_profile_id, reference_profiles, AppError, CloneOpenCodeProfileFromSeed,
|
claude_model_catalogue, codex_model_catalogue, reference_profile_id, reference_profiles,
|
||||||
CloneOpenCodeProfileFromSeedInput, CloneProfileFromSeed, CloneProfileFromSeedInput,
|
AppError, CloneOpenCodeProfileFromSeed, CloneOpenCodeProfileFromSeedInput,
|
||||||
ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
|
CloneProfileFromSeed, CloneProfileFromSeedInput, ConfigureProfiles, ConfigureProfilesInput,
|
||||||
DetectProfilesInput, FirstRunState, ListClaudeModels, ListCodexModels, ListProfiles,
|
DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState,
|
||||||
ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveProfile,
|
ListProfiles, ReferenceProfiles, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||||
SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1161,8 +1161,8 @@ fn catalogue_gemini_and_aider_stay_pty_without_adapter() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_and_codex_model_catalogues_are_static_and_searchable() {
|
fn claude_and_codex_model_catalogues_are_static_and_searchable() {
|
||||||
let claude = ListClaudeModels::new().execute().models;
|
let claude = claude_model_catalogue();
|
||||||
let codex = ListCodexModels::new().execute().models;
|
let codex = codex_model_catalogue();
|
||||||
|
|
||||||
assert!(claude
|
assert!(claude
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@ -1067,6 +1067,10 @@ pub struct ProfileModelCatalogEntryDto {
|
|||||||
pub aliases: Vec<String>,
|
pub aliases: Vec<String>,
|
||||||
/// Whether this entry is the conservative default suggestion.
|
/// Whether this entry is the conservative default suggestion.
|
||||||
pub recommended: bool,
|
pub recommended: bool,
|
||||||
|
/// Compatibility state against the locally detected CLI version.
|
||||||
|
pub compatibility: domain::ModelCompatibility,
|
||||||
|
/// Source that contributed the model entry.
|
||||||
|
pub source: domain::ModelCatalogSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<application::ProfileModelCatalogEntry> for ProfileModelCatalogEntryDto {
|
impl From<application::ProfileModelCatalogEntry> for ProfileModelCatalogEntryDto {
|
||||||
@ -1077,24 +1081,41 @@ impl From<application::ProfileModelCatalogEntry> for ProfileModelCatalogEntryDto
|
|||||||
display_name: entry.display_name,
|
display_name: entry.display_name,
|
||||||
aliases: entry.aliases,
|
aliases: entry.aliases,
|
||||||
recommended: entry.recommended,
|
recommended: entry.recommended,
|
||||||
|
compatibility: entry.compatibility,
|
||||||
|
source: entry.source,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A list of curated structured-profile models.
|
/// Enriched structured-profile model catalogue.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(transparent)]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ProfileModelCatalogDto(pub Vec<ProfileModelCatalogEntryDto>);
|
pub struct ProfileModelCatalogDto {
|
||||||
|
/// The catalogue entries.
|
||||||
|
pub models: Vec<ProfileModelCatalogEntryDto>,
|
||||||
|
/// Best-effort local CLI version.
|
||||||
|
pub cli_version: Option<String>,
|
||||||
|
/// Non-fatal fallback/degradation warnings.
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl From<application::ListClaudeModelsOutput> for ProfileModelCatalogDto {
|
impl From<application::ListClaudeModelsOutput> for ProfileModelCatalogDto {
|
||||||
fn from(out: application::ListClaudeModelsOutput) -> Self {
|
fn from(out: application::ListClaudeModelsOutput) -> Self {
|
||||||
Self(out.models.into_iter().map(Into::into).collect())
|
Self {
|
||||||
|
models: out.models.into_iter().map(Into::into).collect(),
|
||||||
|
cli_version: out.cli_version.map(|version| version.raw),
|
||||||
|
warnings: out.warnings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<application::ListCodexModelsOutput> for ProfileModelCatalogDto {
|
impl From<application::ListCodexModelsOutput> for ProfileModelCatalogDto {
|
||||||
fn from(out: application::ListCodexModelsOutput) -> Self {
|
fn from(out: application::ListCodexModelsOutput) -> Self {
|
||||||
Self(out.models.into_iter().map(Into::into).collect())
|
Self {
|
||||||
|
models: out.models.into_iter().map(Into::into).collect(),
|
||||||
|
cli_version: out.cli_version.map(|version| version.raw),
|
||||||
|
warnings: out.warnings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -80,19 +80,20 @@ use uuid::Uuid;
|
|||||||
use infrastructure::{
|
use infrastructure::{
|
||||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner,
|
||||||
ExternalMcpPluginSupervisor, FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog,
|
EmbeddedCompatibilityMatrix, EmbedderEnvProbe, ExternalMcpPluginSupervisor,
|
||||||
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
|
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
||||||
FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore,
|
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
|
FsIssueStore, FsLiveStateStore, FsMcpToolPermissionStore, FsMemoryStore, FsModelServerRegistry,
|
||||||
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
|
FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore,
|
||||||
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsSystemPermissionStore,
|
FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore,
|
||||||
FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
|
FsSprintStore, FsSystemPermissionStore, FsTemplateStore, FsWindowStateStore, Git2Repository,
|
||||||
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore,
|
HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe,
|
||||||
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
HttpProviderModelCatalogue, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
||||||
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
|
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
|
||||||
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, ReadOnlyRuntimePermissionProbe,
|
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
|
||||||
RwFileGuard, StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
|
PortablePtyAdapter, ProcessCliVersionReader, ReadOnlyRuntimePermissionProbe, RwFileGuard,
|
||||||
|
StructuredSessionFactory, SystemClock, SystemMillisClock, TemplateToolProvider,
|
||||||
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
TicketAssistantEnvironmentPreparer, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler,
|
||||||
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL,
|
||||||
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
@ -1474,8 +1475,21 @@ impl BackendCore {
|
|||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
));
|
));
|
||||||
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
|
let list_opencode_providers = Arc::new(ListOpenCodeProviders::new());
|
||||||
let list_claude_models = Arc::new(ListClaudeModels::new());
|
let cli_version_reader = Arc::new(ProcessCliVersionReader::new(Arc::clone(&spawner_port)));
|
||||||
let list_codex_models = Arc::new(ListCodexModels::new());
|
let provider_model_catalogue = Arc::new(HttpProviderModelCatalogue::new());
|
||||||
|
let compatibility_matrix = Arc::new(EmbeddedCompatibilityMatrix::with_app_data_dir(
|
||||||
|
app_data_dir.clone(),
|
||||||
|
));
|
||||||
|
let list_claude_models = Arc::new(ListClaudeModels::new(
|
||||||
|
Arc::clone(&cli_version_reader) as Arc<dyn domain::ports::CliVersionReader>,
|
||||||
|
Arc::clone(&provider_model_catalogue) as Arc<dyn domain::ports::ProviderModelCatalogue>,
|
||||||
|
Arc::clone(&compatibility_matrix) as Arc<dyn domain::ports::CompatibilityMatrixSource>,
|
||||||
|
));
|
||||||
|
let list_codex_models = Arc::new(ListCodexModels::new(
|
||||||
|
cli_version_reader as Arc<dyn domain::ports::CliVersionReader>,
|
||||||
|
provider_model_catalogue as Arc<dyn domain::ports::ProviderModelCatalogue>,
|
||||||
|
compatibility_matrix as Arc<dyn domain::ports::CompatibilityMatrixSource>,
|
||||||
|
));
|
||||||
let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new(
|
let clone_profile_from_seed = Arc::new(CloneProfileFromSeed::new(
|
||||||
Arc::clone(&profile_store_port),
|
Arc::clone(&profile_store_port),
|
||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
|||||||
@ -51,6 +51,7 @@ pub mod markdown;
|
|||||||
pub mod mcp_tool_permissions;
|
pub mod mcp_tool_permissions;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod memory_harvest;
|
pub mod memory_harvest;
|
||||||
|
pub mod model_catalogue;
|
||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
@ -167,6 +168,11 @@ pub use memory_harvest::{
|
|||||||
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
MAX_BLOCK_BYTES, MAX_DESCRIPTION_CHARS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use model_catalogue::{
|
||||||
|
evaluate_compatibility, CliVersion, CompatibilityMatrix, ModelCatalogSource,
|
||||||
|
ModelCatalogueError, ModelCompatibility,
|
||||||
|
};
|
||||||
|
|
||||||
pub use model_server::{
|
pub use model_server::{
|
||||||
validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef,
|
validate_free_args, ExecutablePath, HfModelRef, LlamaCppOptions, LocalModelRef,
|
||||||
LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint,
|
LocalModelServerConfig, LocalModelServerKind, ModelPath, ModelServerEndpoint,
|
||||||
@ -226,16 +232,17 @@ pub use ports::{
|
|||||||
AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError,
|
AgentContextStore, AgentRuntime, AgentToolPolicyStore, AssistantContextError,
|
||||||
AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion,
|
AssistantContextProvider, BackgroundCompletionStream, BackgroundTaskCompletion,
|
||||||
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
BackgroundTaskHandle, BackgroundTaskPortError, BackgroundTaskRunner, BackgroundTaskSpec,
|
||||||
BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, Embedder, EmbedderEnvInspector,
|
BackgroundTaskStore, CliVersionReader, Clock, CompatibilityMatrixSource, ContextInjectionPlan,
|
||||||
EmbedderEnvReport, EmbedderError, EmbedderProfileStore, EmbedderPromptDismissal,
|
DirEntry, Embedder, EmbedderEnvInspector, EmbedderEnvReport, EmbedderError,
|
||||||
EmbedderPromptStore, EventBus, EventStream, ExitStatus, FileSystem, FsError, GitCommitInfo,
|
EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore, EventBus, EventStream,
|
||||||
GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, IssueNumberAllocator, IssueStore,
|
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
|
||||||
IssueStoreError, LiveStateStore, LocalPath, McpToolPermissionStore, MemoryError, MemoryQuery,
|
IdGenerator, IssueNumberAllocator, IssueStore, IssueStoreError, LiveStateStore, LocalPath,
|
||||||
MemoryRecall, MemoryStore, ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress,
|
McpToolPermissionStore, MemoryError, MemoryQuery, MemoryRecall, MemoryStore,
|
||||||
ModelArtifactResolution, Output, OutputStream, PermissionStore, PluginManifestBytes,
|
ModelArtifactCancel, ModelArtifactDownloader, ModelArtifactProgress, ModelArtifactResolution,
|
||||||
PluginManifestError, PluginManifestValidator, PluginMcpError, PluginMcpSupervisor,
|
Output, OutputStream, PermissionStore, PluginManifestBytes, PluginManifestError,
|
||||||
PluginPackageStore, PluginRegistryError, PluginRegistryStore, PluginStoreError,
|
PluginManifestValidator, PluginMcpError, PluginMcpSupervisor, PluginPackageStore,
|
||||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
PluginRegistryError, PluginRegistryStore, PluginStoreError, PreparedContext, ProcessError,
|
||||||
|
ProcessSpawner, ProfileStore, ProjectStore, ProviderModelCatalogue, PtyError, PtyHandle,
|
||||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
|
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, RuntimePermissionProbe,
|
||||||
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError,
|
||||||
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
StructuredSessionEnvironment, StructuredSessionEnvironmentPreparer, SystemPermissionStore,
|
||||||
|
|||||||
216
crates/domain/src/model_catalogue.rs
Normal file
216
crates/domain/src/model_catalogue.rs
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
//! Pure model-catalogue compatibility types.
|
||||||
|
|
||||||
|
use core::cmp::Ordering;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::profile::StructuredAdapter;
|
||||||
|
|
||||||
|
/// Parsed CLI version used for local compatibility checks.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CliVersion {
|
||||||
|
/// Original version string reported by the CLI.
|
||||||
|
pub raw: String,
|
||||||
|
parts: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliVersion {
|
||||||
|
/// Parses a version from a string containing at least one digit.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`ModelCatalogueError::InvalidVersion`] when no numeric version
|
||||||
|
/// segment can be found.
|
||||||
|
pub fn parse(raw: impl Into<String>) -> Result<Self, ModelCatalogueError> {
|
||||||
|
let raw = raw.into();
|
||||||
|
let start = raw
|
||||||
|
.char_indices()
|
||||||
|
.find_map(|(idx, ch)| ch.is_ascii_digit().then_some(idx))
|
||||||
|
.ok_or_else(|| ModelCatalogueError::InvalidVersion(raw.clone()))?;
|
||||||
|
let version = raw[start..]
|
||||||
|
.chars()
|
||||||
|
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
|
||||||
|
.collect::<String>();
|
||||||
|
let parts = version
|
||||||
|
.split('.')
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.map(str::parse::<u64>)
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(|_| ModelCatalogueError::InvalidVersion(raw.clone()))?;
|
||||||
|
if parts.is_empty() {
|
||||||
|
return Err(ModelCatalogueError::InvalidVersion(raw));
|
||||||
|
}
|
||||||
|
Ok(Self { raw, parts })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for CliVersion {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for CliVersion {
|
||||||
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
|
let max_len = self.parts.len().max(other.parts.len());
|
||||||
|
for idx in 0..max_len {
|
||||||
|
match self
|
||||||
|
.parts
|
||||||
|
.get(idx)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
.cmp(&other.parts.get(idx).copied().unwrap_or(0))
|
||||||
|
{
|
||||||
|
Ordering::Equal => {}
|
||||||
|
ordering => return ordering,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ordering::Equal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility state between a local CLI version and a model id.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ModelCompatibility {
|
||||||
|
/// The known minimum CLI version is satisfied.
|
||||||
|
Compatible,
|
||||||
|
/// The CLI version is absent, or the model is not covered by the matrix.
|
||||||
|
Unknown,
|
||||||
|
/// The model is covered by the matrix but appears newer than the local CLI.
|
||||||
|
LikelyTooRecent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Origin of a model-catalogue entry.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ModelCatalogSource {
|
||||||
|
/// Curated static seed maintained by IdeA.
|
||||||
|
Catalogue,
|
||||||
|
/// Best-effort provider API discovery.
|
||||||
|
Provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matrix mapping adapter/model ids to their minimum known CLI version.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CompatibilityMatrix {
|
||||||
|
/// Matrix schema/data version.
|
||||||
|
pub version: u32,
|
||||||
|
/// Claude Code entries keyed by model id.
|
||||||
|
#[serde(default)]
|
||||||
|
pub claude: HashMap<String, String>,
|
||||||
|
/// Codex CLI entries keyed by model id.
|
||||||
|
#[serde(default)]
|
||||||
|
pub codex: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompatibilityMatrix {
|
||||||
|
/// Looks up the minimum CLI version for an adapter/model pair.
|
||||||
|
#[must_use]
|
||||||
|
pub fn minimum_version(&self, adapter: StructuredAdapter, model_id: &str) -> Option<&str> {
|
||||||
|
match adapter {
|
||||||
|
StructuredAdapter::Claude => self.claude.get(model_id).map(String::as_str),
|
||||||
|
StructuredAdapter::Codex => self.codex.get(model_id).map(String::as_str),
|
||||||
|
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates local compatibility using only pure matrix data.
|
||||||
|
#[must_use]
|
||||||
|
pub fn evaluate_compatibility(
|
||||||
|
matrix: &CompatibilityMatrix,
|
||||||
|
adapter: StructuredAdapter,
|
||||||
|
model_id: &str,
|
||||||
|
cli_version: Option<&CliVersion>,
|
||||||
|
) -> ModelCompatibility {
|
||||||
|
let Some(cli_version) = cli_version else {
|
||||||
|
return ModelCompatibility::Unknown;
|
||||||
|
};
|
||||||
|
let Some(minimum) = matrix.minimum_version(adapter, model_id) else {
|
||||||
|
return ModelCompatibility::Unknown;
|
||||||
|
};
|
||||||
|
let Ok(minimum) = CliVersion::parse(minimum.to_owned()) else {
|
||||||
|
return ModelCompatibility::Unknown;
|
||||||
|
};
|
||||||
|
if &minimum <= cli_version {
|
||||||
|
ModelCompatibility::Compatible
|
||||||
|
} else {
|
||||||
|
ModelCompatibility::LikelyTooRecent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors from pure model-catalogue parsing.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum ModelCatalogueError {
|
||||||
|
/// Version strings must contain at least one numeric segment.
|
||||||
|
#[error("invalid CLI version: {0}")]
|
||||||
|
InvalidVersion(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn matrix() -> CompatibilityMatrix {
|
||||||
|
CompatibilityMatrix {
|
||||||
|
version: 1,
|
||||||
|
claude: HashMap::from([("claude-sonnet-5".to_owned(), "1.2.0".to_owned())]),
|
||||||
|
codex: HashMap::from([("gpt-5-codex".to_owned(), "0.44.0".to_owned())]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_versions_compare_by_numeric_parts() {
|
||||||
|
assert!(CliVersion::parse("codex 0.10.0").unwrap() > CliVersion::parse("0.9.9").unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
CliVersion::parse("1.2")
|
||||||
|
.unwrap()
|
||||||
|
.cmp(&CliVersion::parse("1.2.0").unwrap()),
|
||||||
|
Ordering::Equal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compatibility_is_unknown_without_version_or_matrix_entry() {
|
||||||
|
let matrix = matrix();
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_compatibility(&matrix, StructuredAdapter::Codex, "gpt-5-codex", None),
|
||||||
|
ModelCompatibility::Unknown
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_compatibility(
|
||||||
|
&matrix,
|
||||||
|
StructuredAdapter::Codex,
|
||||||
|
"future-model",
|
||||||
|
Some(&CliVersion::parse("999.0.0").unwrap())
|
||||||
|
),
|
||||||
|
ModelCompatibility::Unknown
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compatibility_detects_supported_and_too_recent_models() {
|
||||||
|
let matrix = matrix();
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_compatibility(
|
||||||
|
&matrix,
|
||||||
|
StructuredAdapter::Codex,
|
||||||
|
"gpt-5-codex",
|
||||||
|
Some(&CliVersion::parse("0.44.0").unwrap())
|
||||||
|
),
|
||||||
|
ModelCompatibility::Compatible
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
evaluate_compatibility(
|
||||||
|
&matrix,
|
||||||
|
StructuredAdapter::Claude,
|
||||||
|
"claude-sonnet-5",
|
||||||
|
Some(&CliVersion::parse("1.1.9").unwrap())
|
||||||
|
),
|
||||||
|
ModelCompatibility::LikelyTooRecent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ use crate::issue::{
|
|||||||
use crate::markdown::MarkdownDoc;
|
use crate::markdown::MarkdownDoc;
|
||||||
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
|
use crate::mcp_tool_permissions::ProjectMcpToolPermissions;
|
||||||
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
|
||||||
|
use crate::model_catalogue::{CliVersion, CompatibilityMatrix};
|
||||||
use crate::model_server::{
|
use crate::model_server::{
|
||||||
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
HfModelRef, LocalModelServerConfig, ModelPath, ModelServerEndpoint, ModelServerStatus,
|
||||||
};
|
};
|
||||||
@ -54,7 +55,7 @@ use crate::plugin::{
|
|||||||
PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome,
|
PluginMcpStatusSet, PluginPackageRef, PluginRegistry, RelativePath, RemovalOutcome,
|
||||||
StagedPluginPackage,
|
StagedPluginPackage,
|
||||||
};
|
};
|
||||||
use crate::profile::{AgentProfile, EmbedderProfile};
|
use crate::profile::{AgentProfile, EmbedderProfile, StructuredAdapter};
|
||||||
use crate::project::{Project, ProjectPath};
|
use crate::project::{Project, ProjectPath};
|
||||||
use crate::remote::RemoteKind;
|
use crate::remote::RemoteKind;
|
||||||
use crate::skill::{Skill, SkillScope};
|
use crate::skill::{Skill, SkillScope};
|
||||||
@ -1245,6 +1246,37 @@ pub trait ProcessSpawner: Send + Sync {
|
|||||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a local structured CLI version using only the allowed `--version` probe.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait CliVersionReader: Send + Sync {
|
||||||
|
/// Best-effort local CLI version lookup.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns a string suitable for non-blocking catalogue warnings.
|
||||||
|
async fn read_cli_version(
|
||||||
|
&self,
|
||||||
|
adapter: StructuredAdapter,
|
||||||
|
) -> Result<Option<CliVersion>, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort provider API model catalogue.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ProviderModelCatalogue: Send + Sync {
|
||||||
|
/// Lists provider model ids for an adapter. `Ok(Vec::new())` means no key or
|
||||||
|
/// unsupported provider and is not a warning-worthy failure.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns a string suitable for non-blocking catalogue warnings.
|
||||||
|
async fn list_provider_models(&self, adapter: StructuredAdapter)
|
||||||
|
-> Result<Vec<String>, String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Source of the versioned compatibility matrix.
|
||||||
|
pub trait CompatibilityMatrixSource: Send + Sync {
|
||||||
|
/// Returns matrix data and any fallback warnings.
|
||||||
|
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>);
|
||||||
|
}
|
||||||
|
|
||||||
/// Probe readiness of an OpenAI-compatible model server.
|
/// Probe readiness of an OpenAI-compatible model server.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait ModelServerProbe: Send + Sync {
|
pub trait ModelServerProbe: Send + Sync {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ pub mod input;
|
|||||||
pub mod inspector;
|
pub mod inspector;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
|
pub mod model_catalogue;
|
||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
pub mod pair_attempt_limiter;
|
pub mod pair_attempt_limiter;
|
||||||
@ -68,6 +69,9 @@ pub use inspector::{
|
|||||||
};
|
};
|
||||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||||
pub use mailbox::InMemoryMailbox;
|
pub use mailbox::InMemoryMailbox;
|
||||||
|
pub use model_catalogue::{
|
||||||
|
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
|
||||||
|
};
|
||||||
pub use model_server::{
|
pub use model_server::{
|
||||||
FsModelServerRegistry, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, LlamaCppRuntime,
|
FsModelServerRegistry, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, LlamaCppRuntime,
|
||||||
LocalManagedProcess,
|
LocalManagedProcess,
|
||||||
|
|||||||
331
crates/infrastructure/src/model_catalogue.rs
Normal file
331
crates/infrastructure/src/model_catalogue.rs
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
//! Concrete adapters for structured model-catalogue enrichment.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::model_catalogue::{CliVersion, CompatibilityMatrix};
|
||||||
|
use domain::ports::{
|
||||||
|
CliVersionReader, CompatibilityMatrixSource, ProcessSpawner, ProviderModelCatalogue, SpawnSpec,
|
||||||
|
};
|
||||||
|
use domain::profile::StructuredAdapter;
|
||||||
|
use domain::project::ProjectPath;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const VERSION_TIMEOUT: Duration = Duration::from_millis(800);
|
||||||
|
const PROVIDER_TIMEOUT: Duration = Duration::from_millis(1_500);
|
||||||
|
const EMBEDDED_MATRIX: &str = include_str!("model_compatibility_matrix.json");
|
||||||
|
|
||||||
|
/// Reads local CLI versions through `codex --version` / `claude --version`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProcessCliVersionReader {
|
||||||
|
spawner: Arc<dyn ProcessSpawner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcessCliVersionReader {
|
||||||
|
/// Builds the adapter from the process-spawner port.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(spawner: Arc<dyn ProcessSpawner>) -> Self {
|
||||||
|
Self { spawner }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spec(adapter: StructuredAdapter) -> Option<SpawnSpec> {
|
||||||
|
let command = match adapter {
|
||||||
|
StructuredAdapter::Claude => "claude",
|
||||||
|
StructuredAdapter::Codex => "codex",
|
||||||
|
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => return None,
|
||||||
|
};
|
||||||
|
Some(SpawnSpec {
|
||||||
|
command: command.to_owned(),
|
||||||
|
args: vec!["--version".to_owned()],
|
||||||
|
cwd: ProjectPath::new("/").expect("root project path is valid"),
|
||||||
|
env: Vec::new(),
|
||||||
|
context_plan: None,
|
||||||
|
sandbox: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl CliVersionReader for ProcessCliVersionReader {
|
||||||
|
async fn read_cli_version(
|
||||||
|
&self,
|
||||||
|
adapter: StructuredAdapter,
|
||||||
|
) -> Result<Option<CliVersion>, String> {
|
||||||
|
let Some(spec) = Self::spec(adapter) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let command = spec.command.clone();
|
||||||
|
let output = tokio::time::timeout(VERSION_TIMEOUT, self.spawner.run(spec))
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("{command} --version timed out"))?
|
||||||
|
.map_err(|e| format!("{command} --version failed: {e}"))?;
|
||||||
|
if output.status.code != Some(0) {
|
||||||
|
return Err(format!(
|
||||||
|
"{command} --version exited with {:?}",
|
||||||
|
output.status.code
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout)
|
||||||
|
.trim()
|
||||||
|
.to_owned()
|
||||||
|
.if_empty_then(|| String::from_utf8_lossy(&output.stderr).trim().to_owned());
|
||||||
|
if text.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
CliVersion::parse(text)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| format!("{command} --version was not parseable: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait EmptyStringExt {
|
||||||
|
fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmptyStringExt for String {
|
||||||
|
fn if_empty_then(self, fallback: impl FnOnce() -> String) -> String {
|
||||||
|
if self.is_empty() {
|
||||||
|
fallback()
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider HTTP catalogue using existing API keys from the process environment.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpProviderModelCatalogue {
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpProviderModelCatalogue {
|
||||||
|
/// Builds the adapter.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_openai(&self, key: String) -> Result<Vec<String>, String> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
data: Vec<Model>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Model {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(
|
||||||
|
PROVIDER_TIMEOUT,
|
||||||
|
self.client
|
||||||
|
.get("https://api.openai.com/v1/models")
|
||||||
|
.bearer_auth(key)
|
||||||
|
.send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "OpenAI model catalogue timed out".to_owned())?
|
||||||
|
.map_err(|e| format!("OpenAI model catalogue unavailable: {e}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"OpenAI model catalogue returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let parsed = response
|
||||||
|
.json::<Response>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("OpenAI model catalogue parse failed: {e}"))?;
|
||||||
|
Ok(dedup_non_empty(
|
||||||
|
parsed.data.into_iter().map(|model| model.id),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_anthropic(&self, key: String) -> Result<Vec<String>, String> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
data: Vec<Model>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Model {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(
|
||||||
|
PROVIDER_TIMEOUT,
|
||||||
|
self.client
|
||||||
|
.get("https://api.anthropic.com/v1/models")
|
||||||
|
.header("x-api-key", key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Anthropic model catalogue timed out".to_owned())?
|
||||||
|
.map_err(|e| format!("Anthropic model catalogue unavailable: {e}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"Anthropic model catalogue returned HTTP {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let parsed = response
|
||||||
|
.json::<Response>()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Anthropic model catalogue parse failed: {e}"))?;
|
||||||
|
Ok(dedup_non_empty(
|
||||||
|
parsed.data.into_iter().map(|model| model.id),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HttpProviderModelCatalogue {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProviderModelCatalogue for HttpProviderModelCatalogue {
|
||||||
|
async fn list_provider_models(
|
||||||
|
&self,
|
||||||
|
adapter: StructuredAdapter,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
match adapter {
|
||||||
|
StructuredAdapter::Codex => match env::var("OPENAI_API_KEY") {
|
||||||
|
Ok(key) if !key.trim().is_empty() => self.list_openai(key).await,
|
||||||
|
_ => Ok(Vec::new()),
|
||||||
|
},
|
||||||
|
StructuredAdapter::Claude => match env::var("ANTHROPIC_API_KEY") {
|
||||||
|
Ok(key) if !key.trim().is_empty() => self.list_anthropic(key).await,
|
||||||
|
_ => Ok(Vec::new()),
|
||||||
|
},
|
||||||
|
StructuredAdapter::OpenCode | StructuredAdapter::OpenAiCompatible => Ok(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dedup_non_empty(values: impl Iterator<Item = String>) -> Vec<String> {
|
||||||
|
values
|
||||||
|
.map(|value| value.trim().to_owned())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compatibility matrix source backed by an embedded JSON seed and optional
|
||||||
|
/// app-data override.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EmbeddedCompatibilityMatrix {
|
||||||
|
override_path: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmbeddedCompatibilityMatrix {
|
||||||
|
/// Builds a matrix source with no override.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
override_path: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a matrix source reading `model-compat.json` from the app data dir
|
||||||
|
/// before falling back to the embedded seed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_app_data_dir(app_data_dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
override_path: Some(app_data_dir.into().join("model-compat.json")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embedded() -> CompatibilityMatrix {
|
||||||
|
serde_json::from_str(EMBEDDED_MATRIX).expect("embedded compatibility matrix is valid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EmbeddedCompatibilityMatrix {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompatibilityMatrixSource for EmbeddedCompatibilityMatrix {
|
||||||
|
fn compatibility_matrix(&self) -> (CompatibilityMatrix, Vec<String>) {
|
||||||
|
let Some(path) = &self.override_path else {
|
||||||
|
return (Self::embedded(), Vec::new());
|
||||||
|
};
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(raw) => match serde_json::from_str::<CompatibilityMatrix>(&raw) {
|
||||||
|
Ok(matrix) => (matrix, Vec::new()),
|
||||||
|
Err(e) => (
|
||||||
|
Self::embedded(),
|
||||||
|
vec![format!(
|
||||||
|
"model compatibility override ignored because it is invalid: {e}"
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (Self::embedded(), Vec::new()),
|
||||||
|
Err(e) => (
|
||||||
|
Self::embedded(),
|
||||||
|
vec![format!(
|
||||||
|
"model compatibility override ignored because it is unreadable: {e}"
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::ports::{ExitStatus, Output, ProcessError};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
struct RecordingSpawner {
|
||||||
|
specs: Mutex<Vec<SpawnSpec>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ProcessSpawner for RecordingSpawner {
|
||||||
|
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
|
||||||
|
self.specs.lock().unwrap().push(spec);
|
||||||
|
Ok(Output {
|
||||||
|
status: ExitStatus { code: Some(0) },
|
||||||
|
stdout: b"codex-cli 0.45.1\n".to_vec(),
|
||||||
|
stderr: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cli_version_reader_runs_only_version_probe() {
|
||||||
|
let spawner = Arc::new(RecordingSpawner {
|
||||||
|
specs: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let reader = ProcessCliVersionReader::new(spawner.clone());
|
||||||
|
let version = reader
|
||||||
|
.read_cli_version(StructuredAdapter::Codex)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(version.raw, "codex-cli 0.45.1");
|
||||||
|
let specs = spawner.specs.lock().unwrap();
|
||||||
|
assert_eq!(specs.len(), 1);
|
||||||
|
assert_eq!(specs[0].command, "codex");
|
||||||
|
assert_eq!(specs[0].args, vec!["--version"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_matrix_is_valid() {
|
||||||
|
let (matrix, warnings) = EmbeddedCompatibilityMatrix::new().compatibility_matrix();
|
||||||
|
assert!(warnings.is_empty());
|
||||||
|
assert!(matrix.codex.contains_key("gpt-5-codex"));
|
||||||
|
assert!(matrix.claude.contains_key("claude-sonnet-5"));
|
||||||
|
}
|
||||||
|
}
|
||||||
13
crates/infrastructure/src/model_compatibility_matrix.json
Normal file
13
crates/infrastructure/src/model_compatibility_matrix.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"claude": {
|
||||||
|
"claude-sonnet-5": "1.0.0",
|
||||||
|
"claude-opus-4-8": "1.0.0",
|
||||||
|
"claude-haiku-4-5-20251001": "1.0.0"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"gpt-5-codex": "0.1.0",
|
||||||
|
"gpt-5": "0.1.0",
|
||||||
|
"gpt-5-mini": "0.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -79,18 +79,18 @@ use backend::dto::{
|
|||||||
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
|
||||||
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
LaunchAgentRequestDto, LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto,
|
||||||
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
MemoryListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ProfileDto, ProfileListDto,
|
||||||
ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||||
ReadConversationPageRequestDto, RecallMemoryRequestDto, ResolveAgentPermissionsRequestDto,
|
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResolveAgentPermissionsRequestDto, ResolveAgentSystemPermissionsRequestDto,
|
||||||
ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
ResolvedAgentSystemPermissionsDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto,
|
||||||
SaveProfileRequestDto, SkillDto, SkillListDto, SprintCreateRequestDto, SprintDeleteRequestDto,
|
SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SkillDto, SkillListDto,
|
||||||
SprintDto, SprintListDto, SprintListRequestDto, SprintRenameRequestDto,
|
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
|
||||||
SprintReorderRequestDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
|
||||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
|
||||||
TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto, TicketCreateRequestDto,
|
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketCarnetDto,
|
||||||
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
|
TicketCreateRequestDto, TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto,
|
||||||
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
TicketListPageInput, TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
|
||||||
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
|
||||||
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContextRequestDto,
|
||||||
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
|
||||||
@ -2353,6 +2353,8 @@ async fn invoke(
|
|||||||
"list_profiles" => invoke_list_profiles(&state.app).await,
|
"list_profiles" => invoke_list_profiles(&state.app).await,
|
||||||
"save_profile" => invoke_save_profile(&request.args, &state.app).await,
|
"save_profile" => invoke_save_profile(&request.args, &state.app).await,
|
||||||
"list_opencode_providers" => invoke_list_opencode_providers(&state.app),
|
"list_opencode_providers" => invoke_list_opencode_providers(&state.app),
|
||||||
|
"list_claude_models" => invoke_list_claude_models(&state.app).await,
|
||||||
|
"list_codex_models" => invoke_list_codex_models(&state.app).await,
|
||||||
"save_opencode_provider_profile" => {
|
"save_opencode_provider_profile" => {
|
||||||
invoke_save_opencode_provider_profile(&request.args, &state.app).await
|
invoke_save_opencode_provider_profile(&request.args, &state.app).await
|
||||||
}
|
}
|
||||||
@ -2606,6 +2608,16 @@ fn invoke_list_opencode_providers(state: &BackendCore) -> Result<Value, ErrorDto
|
|||||||
serde_json::to_value(output).map_err(serialization_error)
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn invoke_list_claude_models(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let output: ProfileModelCatalogDto = state.list_claude_models.execute().await.into();
|
||||||
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn invoke_list_codex_models(state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||||
|
let output: ProfileModelCatalogDto = state.list_codex_models.execute().await.into();
|
||||||
|
serde_json::to_value(output).map_err(serialization_error)
|
||||||
|
}
|
||||||
|
|
||||||
async fn invoke_save_opencode_provider_profile(
|
async fn invoke_save_opencode_provider_profile(
|
||||||
args: &Value,
|
args: &Value,
|
||||||
state: &BackendCore,
|
state: &BackendCore,
|
||||||
@ -7653,6 +7665,8 @@ mod tests {
|
|||||||
"list_profiles",
|
"list_profiles",
|
||||||
"save_profile",
|
"save_profile",
|
||||||
"list_opencode_providers",
|
"list_opencode_providers",
|
||||||
|
"list_claude_models",
|
||||||
|
"list_codex_models",
|
||||||
"save_opencode_provider_profile",
|
"save_opencode_provider_profile",
|
||||||
"delete_profile",
|
"delete_profile",
|
||||||
"configure_profiles",
|
"configure_profiles",
|
||||||
|
|||||||
@ -42,7 +42,7 @@ import type {
|
|||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
ProjectSystemPermissions,
|
ProjectSystemPermissions,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
ProfileModelCatalogEntry,
|
ProfileModelCatalog,
|
||||||
ResolvedAgentSystemPermissions,
|
ResolvedAgentSystemPermissions,
|
||||||
SystemPermissionSet,
|
SystemPermissionSet,
|
||||||
Skill,
|
Skill,
|
||||||
@ -74,6 +74,7 @@ import type {
|
|||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { normalizeProjectWorkState } from "../workStateNormalization";
|
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||||
import { normalizeTurnPage } from "../conversationNormalization";
|
import { normalizeTurnPage } from "../conversationNormalization";
|
||||||
|
import { normalizeProfileModelCatalog } from "../profileCatalog";
|
||||||
import type { HttpInvoker } from "./httpInvoker";
|
import type { HttpInvoker } from "./httpInvoker";
|
||||||
|
|
||||||
export class HttpProjectGateway implements ProjectGateway {
|
export class HttpProjectGateway implements ProjectGateway {
|
||||||
@ -183,11 +184,11 @@ export class HttpProfileGateway implements ProfileGateway {
|
|||||||
request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model },
|
request: { seedProfileId: input.seedProfileId, name: input.name, model: input.model },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
async listClaudeModels(): Promise<ProfileModelCatalog> {
|
||||||
return this.http.invoke<ProfileModelCatalogEntry[]>("list_claude_models");
|
return normalizeProfileModelCatalog(await this.http.invoke<unknown>("list_claude_models"));
|
||||||
}
|
}
|
||||||
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
async listCodexModels(): Promise<ProfileModelCatalog> {
|
||||||
return this.http.invoke<ProfileModelCatalogEntry[]>("list_codex_models");
|
return normalizeProfileModelCatalog(await this.http.invoke<unknown>("list_codex_models"));
|
||||||
}
|
}
|
||||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
||||||
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
|
return this.http.invoke<AgentProfile[]>("configure_profiles", { request: { profiles } });
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import type {
|
|||||||
McpToolCatalogue,
|
McpToolCatalogue,
|
||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
|
ProfileModelCatalog,
|
||||||
ProfileModelCatalogEntry,
|
ProfileModelCatalogEntry,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
@ -1302,6 +1303,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
|
|||||||
displayName: "Claude Sonnet 5",
|
displayName: "Claude Sonnet 5",
|
||||||
aliases: ["sonnet"],
|
aliases: ["sonnet"],
|
||||||
recommended: true,
|
recommended: true,
|
||||||
|
compatibility: "compatible",
|
||||||
|
source: "catalogue",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
adapter: "claude",
|
adapter: "claude",
|
||||||
@ -1309,6 +1312,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
|
|||||||
displayName: "Claude Opus 4.8",
|
displayName: "Claude Opus 4.8",
|
||||||
aliases: ["opus"],
|
aliases: ["opus"],
|
||||||
recommended: false,
|
recommended: false,
|
||||||
|
compatibility: "unknown",
|
||||||
|
source: "catalogue",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
adapter: "claude",
|
adapter: "claude",
|
||||||
@ -1316,6 +1321,8 @@ const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
|
|||||||
displayName: "Claude Haiku 4.5",
|
displayName: "Claude Haiku 4.5",
|
||||||
aliases: ["haiku"],
|
aliases: ["haiku"],
|
||||||
recommended: false,
|
recommended: false,
|
||||||
|
compatibility: "likelyTooRecent",
|
||||||
|
source: "provider",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -1326,6 +1333,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
|
|||||||
displayName: "GPT-5 Codex",
|
displayName: "GPT-5 Codex",
|
||||||
aliases: ["codex"],
|
aliases: ["codex"],
|
||||||
recommended: true,
|
recommended: true,
|
||||||
|
compatibility: "compatible",
|
||||||
|
source: "catalogue",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
adapter: "codex",
|
adapter: "codex",
|
||||||
@ -1333,6 +1342,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
|
|||||||
displayName: "GPT-5",
|
displayName: "GPT-5",
|
||||||
aliases: ["general"],
|
aliases: ["general"],
|
||||||
recommended: false,
|
recommended: false,
|
||||||
|
compatibility: "unknown",
|
||||||
|
source: "catalogue",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
adapter: "codex",
|
adapter: "codex",
|
||||||
@ -1340,6 +1351,8 @@ const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
|
|||||||
displayName: "GPT-5 mini",
|
displayName: "GPT-5 mini",
|
||||||
aliases: ["mini", "fast"],
|
aliases: ["mini", "fast"],
|
||||||
recommended: false,
|
recommended: false,
|
||||||
|
compatibility: "likelyTooRecent",
|
||||||
|
source: "provider",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -1420,12 +1433,20 @@ export class MockProfileGateway implements ProfileGateway {
|
|||||||
return structuredClone(cloned);
|
return structuredClone(cloned);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
async listClaudeModels(): Promise<ProfileModelCatalog> {
|
||||||
return structuredClone(MOCK_CLAUDE_MODELS);
|
return {
|
||||||
|
models: structuredClone(MOCK_CLAUDE_MODELS),
|
||||||
|
cliVersion: "2.1.220",
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
async listCodexModels(): Promise<ProfileModelCatalog> {
|
||||||
return structuredClone(MOCK_CODEX_MODELS);
|
return {
|
||||||
|
models: structuredClone(MOCK_CODEX_MODELS),
|
||||||
|
cliVersion: "0.145.0",
|
||||||
|
warnings: ["Catalogue provider partiellement estime depuis les donnees locales."],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async cloneOpenCodeProfileFromSeed(
|
async cloneOpenCodeProfileFromSeed(
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import type {
|
|||||||
AgentProfile,
|
AgentProfile,
|
||||||
FirstRunState,
|
FirstRunState,
|
||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
ProfileModelCatalogEntry,
|
ProfileModelCatalog,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
@ -21,6 +21,7 @@ import type {
|
|||||||
ProfileGateway,
|
ProfileGateway,
|
||||||
SaveOpenCodeProviderProfileInput,
|
SaveOpenCodeProviderProfileInput,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
|
import { normalizeProfileModelCatalog } from "./profileCatalog";
|
||||||
|
|
||||||
export class TauriProfileGateway implements ProfileGateway {
|
export class TauriProfileGateway implements ProfileGateway {
|
||||||
firstRunState(): Promise<FirstRunState> {
|
firstRunState(): Promise<FirstRunState> {
|
||||||
@ -59,12 +60,12 @@ export class TauriProfileGateway implements ProfileGateway {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
listClaudeModels(): Promise<ProfileModelCatalogEntry[]> {
|
async listClaudeModels(): Promise<ProfileModelCatalog> {
|
||||||
return invoke<ProfileModelCatalogEntry[]>("list_claude_models");
|
return normalizeProfileModelCatalog(await invoke<unknown>("list_claude_models"));
|
||||||
}
|
}
|
||||||
|
|
||||||
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
async listCodexModels(): Promise<ProfileModelCatalog> {
|
||||||
return invoke<ProfileModelCatalogEntry[]>("list_codex_models");
|
return normalizeProfileModelCatalog(await invoke<unknown>("list_codex_models"));
|
||||||
}
|
}
|
||||||
|
|
||||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
|
||||||
|
|||||||
56
frontend/src/adapters/profileCatalog.test.ts
Normal file
56
frontend/src/adapters/profileCatalog.test.ts
Normal 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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
63
frontend/src/adapters/profileCatalog.ts
Normal file
63
frontend/src/adapters/profileCatalog.ts
Normal 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)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1096,6 +1096,15 @@ export interface OpenCodeProviderCatalogEntry {
|
|||||||
models: string[];
|
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. */
|
/** One searchable model from the Codex/Claude structured-profile catalogues. */
|
||||||
export interface ProfileModelCatalogEntry {
|
export interface ProfileModelCatalogEntry {
|
||||||
/** Structured adapter this model belongs to. */
|
/** Structured adapter this model belongs to. */
|
||||||
@ -1108,6 +1117,19 @@ export interface ProfileModelCatalogEntry {
|
|||||||
aliases: string[];
|
aliases: string[];
|
||||||
/** Whether this entry is the conservative default suggestion. */
|
/** Whether this entry is the conservative default suggestion. */
|
||||||
recommended: boolean;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
|||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { MockProfileGateway } from "@/adapters/mock";
|
import { MockProfileGateway } from "@/adapters/mock";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import type { ProfileModelCatalogEntry } from "@/domain";
|
import type { ProfileModelCatalog } from "@/domain";
|
||||||
import { ProfilesSettings } from "./ProfilesSettings";
|
import { ProfilesSettings } from "./ProfilesSettings";
|
||||||
|
|
||||||
function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) {
|
function renderSettings(profile: MockProfileGateway = new MockProfileGateway()) {
|
||||||
@ -94,14 +94,14 @@ describe("ProfilesSettings", () => {
|
|||||||
|
|
||||||
it("keeps manual model entry available when the catalogue fails", async () => {
|
it("keeps manual model entry available when the catalogue fails", async () => {
|
||||||
class CatalogueDownProfileGateway extends MockProfileGateway {
|
class CatalogueDownProfileGateway extends MockProfileGateway {
|
||||||
listCodexModels(): Promise<ProfileModelCatalogEntry[]> {
|
listCodexModels(): Promise<ProfileModelCatalog> {
|
||||||
return Promise.reject(new Error("catalogue down"));
|
return Promise.reject(new Error("catalogue down"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSettings(new CatalogueDownProfileGateway());
|
renderSettings(new CatalogueDownProfileGateway());
|
||||||
await waitReady();
|
await waitReady();
|
||||||
expect(await screen.findByText(/saisie manuelle active/)).toBeTruthy();
|
expect(await screen.findByText(/Catalogue provider indisponible/)).toBeTruthy();
|
||||||
|
|
||||||
await createProfile();
|
await createProfile();
|
||||||
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
|
const model = within(screen.getAllByRole("listitem")[0]).getByLabelText(
|
||||||
@ -109,5 +109,54 @@ describe("ProfilesSettings", () => {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
fireEvent.change(model, { target: { value: "future-codex-model" } });
|
fireEvent.change(model, { target: { value: "future-codex-model" } });
|
||||||
expect(model.value).toBe("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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,12 +8,16 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import type {
|
import type {
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
|
ModelCompatibility,
|
||||||
|
ProfileModelCatalog,
|
||||||
ProfileModelCatalogEntry,
|
ProfileModelCatalogEntry,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { Button, Input, Panel, cn } from "@/shared";
|
import { Button, Input, Panel, cn } from "@/shared";
|
||||||
|
|
||||||
type ProfileTab = "codex" | "claude" | "openCode";
|
type ProfileTab = "codex" | "claude" | "openCode";
|
||||||
|
type ModelTab = "codex" | "claude";
|
||||||
|
type CatalogState = Record<ModelTab, ProfileModelCatalog & { unavailable: boolean }>;
|
||||||
|
|
||||||
const TABS: Array<{ id: ProfileTab; label: string }> = [
|
const TABS: Array<{ id: ProfileTab; label: string }> = [
|
||||||
{ id: "codex", label: "Codex" },
|
{ id: "codex", label: "Codex" },
|
||||||
@ -21,9 +25,16 @@ const TABS: Array<{ id: ProfileTab; label: string }> = [
|
|||||||
{ id: "openCode", label: "OpenCode-local" },
|
{ id: "openCode", label: "OpenCode-local" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const EMPTY_CATALOGUE: Record<"codex" | "claude", ProfileModelCatalogEntry[]> = {
|
const EMPTY_MODEL_CATALOG: ProfileModelCatalog & { unavailable: boolean } = {
|
||||||
codex: [],
|
models: [],
|
||||||
claude: [],
|
cliVersion: null,
|
||||||
|
warnings: [],
|
||||||
|
unavailable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_CATALOGUE: CatalogState = {
|
||||||
|
codex: EMPTY_MODEL_CATALOG,
|
||||||
|
claude: EMPTY_MODEL_CATALOG,
|
||||||
};
|
};
|
||||||
|
|
||||||
function describe(e: unknown): string {
|
function describe(e: unknown): string {
|
||||||
@ -66,6 +77,185 @@ function optionLabel(entry: ProfileModelCatalogEntry): string {
|
|||||||
: `${entry.displayName} (${entry.modelId})`;
|
: `${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() {
|
export function ProfilesSettings() {
|
||||||
const { profile } = useGateways();
|
const { profile } = useGateways();
|
||||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||||
@ -75,6 +265,7 @@ export function ProfilesSettings() {
|
|||||||
const [drafts, setDrafts] = useState<Record<string, AgentProfile>>({});
|
const [drafts, setDrafts] = useState<Record<string, AgentProfile>>({});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [catalogueWarning, setCatalogueWarning] = useState<string | null>(null);
|
const [catalogueWarning, setCatalogueWarning] = useState<string | null>(null);
|
||||||
|
const [saveWarnings, setSaveWarnings] = useState<Record<string, string>>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
@ -106,12 +297,18 @@ export function ProfilesSettings() {
|
|||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCatalogue({
|
setCatalogue({
|
||||||
codex: codex.status === "fulfilled" ? codex.value : [],
|
codex:
|
||||||
claude: claude.status === "fulfilled" ? claude.value : [],
|
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") {
|
if (codex.status === "rejected" || claude.status === "rejected") {
|
||||||
setCatalogueWarning(
|
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 {
|
try {
|
||||||
const models =
|
const models =
|
||||||
activeTab === "codex" || activeTab === "claude"
|
activeTab === "codex" || activeTab === "claude"
|
||||||
? catalogue[activeTab]
|
? catalogue[activeTab].models
|
||||||
: [];
|
: [];
|
||||||
const recommended = models.find((m) => m.recommended)?.modelId;
|
const recommended = models.find((m) => m.recommended)?.modelId;
|
||||||
await profile.cloneProfileFromSeed({
|
await profile.cloneProfileFromSeed({
|
||||||
@ -167,9 +364,24 @@ export function ProfilesSettings() {
|
|||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSaveWarnings((prev) => {
|
||||||
|
const { [id]: _ignored, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
try {
|
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 profile.saveProfile(draft);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
if (warning) {
|
||||||
|
setSaveWarnings((prev) => ({ ...prev, [id]: warning }));
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(describe(e));
|
setError(describe(e));
|
||||||
} finally {
|
} finally {
|
||||||
@ -212,7 +424,9 @@ export function ProfilesSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modelOptions =
|
const modelOptions =
|
||||||
activeTab === "codex" || activeTab === "claude" ? catalogue[activeTab] : [];
|
activeTab === "codex" || activeTab === "claude"
|
||||||
|
? catalogue[activeTab].models
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
@ -258,13 +472,12 @@ export function ProfilesSettings() {
|
|||||||
<p className="text-xs text-muted">{catalogueWarning}</p>
|
<p className="text-xs text-muted">{catalogueWarning}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<datalist id={`profile-models-${activeTab}`}>
|
{(activeTab === "codex" || activeTab === "claude") &&
|
||||||
{modelOptions.map((entry) => (
|
catalogue[activeTab].warnings.map((warning) => (
|
||||||
<option key={entry.modelId} value={entry.modelId}>
|
<p key={warning} className="text-xs text-warning">
|
||||||
{optionLabel(entry)}
|
{warning}
|
||||||
</option>
|
</p>
|
||||||
))}
|
))}
|
||||||
</datalist>
|
|
||||||
|
|
||||||
{visibleProfiles.length === 0 ? (
|
{visibleProfiles.length === 0 ? (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
@ -296,25 +509,42 @@ export function ProfilesSettings() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex min-w-0 flex-col gap-1">
|
{activeTab === "codex" || activeTab === "claude" ? (
|
||||||
<span className="text-xs font-medium text-muted">Modele</span>
|
<ModelField
|
||||||
<Input
|
profileId={saved.id}
|
||||||
aria-label={`modele du profil ${saved.name}`}
|
profileName={saved.name}
|
||||||
list={`profile-models-${activeTab}`}
|
tab={activeTab}
|
||||||
placeholder={
|
model={model}
|
||||||
modelOptions.length > 0
|
catalog={catalogue[activeTab]}
|
||||||
? "Choisir ou saisir un modele"
|
onChange={(value) =>
|
||||||
: "Saisir un modele"
|
updateDraft(saved.id, (p) => withModel(p, value))
|
||||||
}
|
|
||||||
value={model}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateDraft(saved.id, (p) =>
|
|
||||||
withModel(p, e.target.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>
|
</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">
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
<code className="min-w-0 truncate text-xs text-muted">
|
<code className="min-w-0 truncate text-xs text-muted">
|
||||||
|
|||||||
@ -37,7 +37,7 @@ import type {
|
|||||||
McpToolPolicy,
|
McpToolPolicy,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
OpenCodeProviderCatalogEntry,
|
OpenCodeProviderCatalogEntry,
|
||||||
ProfileModelCatalogEntry,
|
ProfileModelCatalog,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PairedDevice,
|
PairedDevice,
|
||||||
PairingCode,
|
PairingCode,
|
||||||
@ -670,10 +670,10 @@ export interface ProfileGateway {
|
|||||||
* Used by Settings duplication for Codex/Claude/OpenCode identity copies.
|
* Used by Settings duplication for Codex/Claude/OpenCode identity copies.
|
||||||
*/
|
*/
|
||||||
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile>;
|
cloneProfileFromSeed(input: CloneProfileFromSeedInput): Promise<AgentProfile>;
|
||||||
/** Curated Claude Code model catalogue. Manual model entry remains supported. */
|
/** Enriched Claude Code model catalogue. Manual model entry remains supported. */
|
||||||
listClaudeModels(): Promise<ProfileModelCatalogEntry[]>;
|
listClaudeModels(): Promise<ProfileModelCatalog>;
|
||||||
/** Curated Codex CLI model catalogue. Manual model entry remains supported. */
|
/** Enriched Codex CLI model catalogue. Manual model entry remains supported. */
|
||||||
listCodexModels(): Promise<ProfileModelCatalogEntry[]>;
|
listCodexModels(): Promise<ProfileModelCatalog>;
|
||||||
/** Persists the batch of chosen profiles, closing the first run. */
|
/** Persists the batch of chosen profiles, closing the first run. */
|
||||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user