diff --git a/lib/crates/fabro-cli/tests/cmd/model/bare.trycmd b/lib/crates/fabro-cli/tests/cmd/model/bare.trycmd index 34330cc50..9498915fb 100644 --- a/lib/crates/fabro-cli/tests/cmd/model/bare.trycmd +++ b/lib/crates/fabro-cli/tests/cmd/model/bare.trycmd @@ -1,25 +1,25 @@ ```console $ fabro model - MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  - claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s  - claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s  - claude-sonnet-4-6   anthropic  sonnet, claude-sonnet   200k   $3.0 / $15.0   50 tok/s  - claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s  - gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s  - gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s  - gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s  - gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s  - gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s  - gpt-5.4   openai   gpt54    1m   $2.5 / $15.0   70 tok/s  - gpt-5.4-pro   openai   gpt54-pro    1m  $30.0 / $180.0   20 tok/s  - gpt-5.4-mini   openai   gpt54-mini    400k   $0.8 / $4.5   140 tok/s  - gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s  - gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s  - gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s  - gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s  - kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s  - glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s  - minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s  - mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s  + MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  + claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s  + claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s  + claude-sonnet-4-6   anthropic  sonnet, claude-sonnet    200k   $3.0 / $15.0   50 tok/s  + claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s  + gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s  + gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s  + gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s  + gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s  + gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s  + gpt-5.4   openai   gpt54, gpt-54    1m   $2.5 / $15.0   70 tok/s  + gpt-5.4-pro   openai   gpt54-pro, gpt-54-pro    1m  $30.0 / $180.0   20 tok/s  + gpt-5.4-mini   openai   gpt54-mini, gpt-54-mini   400k   $0.8 / $4.5   140 tok/s  + gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s  + gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s  + gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s  + gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s  + kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s  + glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s  + minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s  + mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s   ``` diff --git a/lib/crates/fabro-cli/tests/cmd/model/list.trycmd b/lib/crates/fabro-cli/tests/cmd/model/list.trycmd index fae6771f8..cd4c85681 100644 --- a/lib/crates/fabro-cli/tests/cmd/model/list.trycmd +++ b/lib/crates/fabro-cli/tests/cmd/model/list.trycmd @@ -1,25 +1,25 @@ ```console $ fabro model list - MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  - claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s  - claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s  - claude-sonnet-4-6   anthropic  sonnet, claude-sonnet   200k   $3.0 / $15.0   50 tok/s  - claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s  - gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s  - gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s  - gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s  - gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s  - gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s  - gpt-5.4   openai   gpt54    1m   $2.5 / $15.0   70 tok/s  - gpt-5.4-pro   openai   gpt54-pro    1m  $30.0 / $180.0   20 tok/s  - gpt-5.4-mini   openai   gpt54-mini    400k   $0.8 / $4.5   140 tok/s  - gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s  - gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s  - gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s  - gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s  - kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s  - glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s  - minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s  - mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s  + MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  + claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s  + claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s  + claude-sonnet-4-6   anthropic  sonnet, claude-sonnet    200k   $3.0 / $15.0   50 tok/s  + claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s  + gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s  + gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s  + gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s  + gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s  + gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s  + gpt-5.4   openai   gpt54, gpt-54    1m   $2.5 / $15.0   70 tok/s  + gpt-5.4-pro   openai   gpt54-pro, gpt-54-pro    1m  $30.0 / $180.0   20 tok/s  + gpt-5.4-mini   openai   gpt54-mini, gpt-54-mini   400k   $0.8 / $4.5   140 tok/s  + gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s  + gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s  + gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s  + gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s  + kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s  + glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s  + minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s  + mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s   ``` diff --git a/lib/crates/fabro-model/src/catalog.json b/lib/crates/fabro-model/src/catalog.json index 608dd11d0..f05f13019 100644 --- a/lib/crates/fabro-model/src/catalog.json +++ b/lib/crates/fabro-model/src/catalog.json @@ -158,7 +158,7 @@ "cache_input_cost_per_mtok": 0.25 }, "estimated_output_tps": 70, - "aliases": ["gpt54"], + "aliases": ["gpt54", "gpt-54"], "default": true }, { @@ -175,7 +175,7 @@ "cache_input_cost_per_mtok": 3.0 }, "estimated_output_tps": 20, - "aliases": ["gpt54-pro"] + "aliases": ["gpt54-pro", "gpt-54-pro"] }, { "id": "gpt-5.4-mini", @@ -191,7 +191,7 @@ "cache_input_cost_per_mtok": 0.075 }, "estimated_output_tps": 140, - "aliases": ["gpt54-mini"] + "aliases": ["gpt54-mini", "gpt-54-mini"] }, { "id": "gemini-3.1-pro-preview", diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 8095aa527..ae90f4425 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -525,6 +525,7 @@ mod tests { ), aliases: [ "gpt54", + "gpt-54", ], default: true, } @@ -570,6 +571,7 @@ mod tests { ), aliases: [ "gpt54-pro", + "gpt-54-pro", ], default: false, } @@ -581,6 +583,21 @@ mod tests { assert_eq!(get_model_info("gpt54").unwrap().id, "gpt-5.4"); } + #[test] + fn gpt_54_hyphenated_alias() { + assert_eq!(get_model_info("gpt-54").unwrap().id, "gpt-5.4"); + } + + #[test] + fn gpt_54_pro_hyphenated_alias() { + assert_eq!(get_model_info("gpt-54-pro").unwrap().id, "gpt-5.4-pro"); + } + + #[test] + fn gpt_54_mini_hyphenated_alias() { + assert_eq!(get_model_info("gpt-54-mini").unwrap().id, "gpt-5.4-mini"); + } + #[test] fn gpt_5_3_codex_spark_in_catalog() { let m = get_model_info("gpt-5.3-codex-spark").unwrap(); diff --git a/lib/crates/fabro-validate/src/rules.rs b/lib/crates/fabro-validate/src/rules.rs index 82cd09c9b..d2c9057e7 100644 --- a/lib/crates/fabro-validate/src/rules.rs +++ b/lib/crates/fabro-validate/src/rules.rs @@ -31,6 +31,7 @@ pub fn built_in_rules() -> Vec> { Box::new(OrphanCustomOutcomeRule), Box::new(ScriptAbsoluteCdRule), Box::new(StylesheetModelKnownRule), + Box::new(NodeModelKnownRule), Box::new(UnresolvedFileRefRule), Box::new(ThreadIdRequiresFidelityFullRule), Box::new(SelectionValidRule), @@ -898,6 +899,53 @@ impl LintRule for ScriptAbsoluteCdRule { } } +// --- Shared helpers for model/provider validation --- + +fn check_model_known( + rule_name: &str, + model: &str, + context: &str, + node_id: Option, +) -> Option { + if fabro_model::get_model_info(model).is_some() { + return None; + } + Some(Diagnostic { + rule: rule_name.to_string(), + severity: Severity::Warning, + message: format!( + "Unknown model '{model}' {context}. Run `fabro model list` to see available models" + ), + node_id, + edge: None, + fix: Some("Use a model ID from `fabro model list`".to_string()), + }) +} + +fn check_provider_known( + rule_name: &str, + provider: &str, + context: &str, + node_id: Option, +) -> Option { + if fabro_model::Provider::from_str(provider).is_ok() { + return None; + } + let valid: Vec<&str> = fabro_model::Provider::ALL + .iter() + .map(|p| p.as_str()) + .collect(); + let valid_str = valid.join(", "); + Some(Diagnostic { + rule: rule_name.to_string(), + severity: Severity::Warning, + message: format!("Unknown provider '{provider}' {context}. Valid providers: {valid_str}"), + node_id, + edge: None, + fix: Some(format!("Use one of: {valid_str}")), + }) +} + // --- Rule 20: stylesheet_model_known (WARNING) --- struct StylesheetModelKnownRule; @@ -932,40 +980,19 @@ impl LintRule for StylesheetModelKnownRule { for rule in &stylesheet.rules { let label = Self::selector_label(&rule.selector); for decl in &rule.declarations { + let context = format!("in stylesheet rule '{label}'"); match decl.property.as_str() { "model" => { - if fabro_model::get_model_info(&decl.value).is_none() { - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Unknown model '{}' in stylesheet rule '{label}'. Run `fabro model list` to see available models", - decl.value - ), - node_id: None, - edge: None, - fix: Some("Use a model ID from `fabro model list`".to_string()), - }); + if let Some(d) = check_model_known(self.name(), &decl.value, &context, None) + { + diagnostics.push(d); } } "provider" => { - if fabro_model::Provider::from_str(&decl.value).is_err() { - let valid: Vec<&str> = fabro_model::Provider::ALL - .iter() - .map(|p| p.as_str()) - .collect(); - diagnostics.push(Diagnostic { - rule: self.name().to_string(), - severity: Severity::Warning, - message: format!( - "Unknown provider '{}' in stylesheet rule '{label}'. Valid providers: {}", - decl.value, - valid.join(", ") - ), - node_id: None, - edge: None, - fix: Some(format!("Use one of: {}", valid.join(", "))), - }); + if let Some(d) = + check_provider_known(self.name(), &decl.value, &context, None) + { + diagnostics.push(d); } } _ => {} @@ -976,7 +1003,38 @@ impl LintRule for StylesheetModelKnownRule { } } -// --- Rule 21: unresolved_file_ref (ERROR) --- +// --- Rule 21: node_model_known (WARNING) --- + +struct NodeModelKnownRule; + +impl LintRule for NodeModelKnownRule { + fn name(&self) -> &'static str { + "node_model_known" + } + + fn apply(&self, graph: &Graph) -> Vec { + let mut diagnostics = Vec::new(); + for node in graph.nodes.values() { + let context = format!("on node '{}'", node.id); + let node_id = Some(node.id.clone()); + if let Some(model) = node.model() { + if let Some(d) = check_model_known(self.name(), model, &context, node_id.clone()) { + diagnostics.push(d); + } + } + if let Some(provider) = node.provider() { + if let Some(d) = + check_provider_known(self.name(), provider, &context, node_id.clone()) + { + diagnostics.push(d); + } + } + } + diagnostics + } +} + +// --- Rule 22: unresolved_file_ref (ERROR) --- struct UnresolvedFileRefRule; @@ -3035,6 +3093,76 @@ mod tests { assert!(d.is_empty()); } + // node_model_known rule tests + + #[test] + fn node_model_known_rule_valid_model() { + let mut g = minimal_graph(); + let mut node = Node::new("work"); + node.attrs.insert( + "model".to_string(), + AttrValue::String("claude-sonnet-4-5".to_string()), + ); + g.nodes.insert("work".to_string(), node); + let rule = NodeModelKnownRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn node_model_known_rule_unknown_model() { + let mut g = minimal_graph(); + let mut node = Node::new("work"); + node.attrs.insert( + "model".to_string(), + AttrValue::String("nonexistent-model-xyz".to_string()), + ); + g.nodes.insert("work".to_string(), node); + let rule = NodeModelKnownRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Warning); + assert!(d[0].message.contains("nonexistent-model-xyz")); + assert_eq!(d[0].node_id.as_deref(), Some("work")); + } + + #[test] + fn node_model_known_rule_alias() { + let mut g = minimal_graph(); + let mut node = Node::new("work"); + node.attrs + .insert("model".to_string(), AttrValue::String("opus".to_string())); + g.nodes.insert("work".to_string(), node); + let rule = NodeModelKnownRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + + #[test] + fn node_model_known_rule_unknown_provider() { + let mut g = minimal_graph(); + let mut node = Node::new("work"); + node.attrs.insert( + "provider".to_string(), + AttrValue::String("google".to_string()), + ); + g.nodes.insert("work".to_string(), node); + let rule = NodeModelKnownRule; + let d = rule.apply(&g); + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Warning); + assert!(d[0].message.contains("google")); + assert_eq!(d[0].node_id.as_deref(), Some("work")); + } + + #[test] + fn node_model_known_rule_no_model_no_provider() { + let g = minimal_graph(); + let rule = NodeModelKnownRule; + let d = rule.apply(&g); + assert!(d.is_empty()); + } + // unresolved_file_ref rule tests #[test] diff --git a/lib/crates/fabro-workflows/src/git.rs b/lib/crates/fabro-workflows/src/git.rs index dbd0430e2..0c6a647de 100644 --- a/lib/crates/fabro-workflows/src/git.rs +++ b/lib/crates/fabro-workflows/src/git.rs @@ -1149,51 +1149,6 @@ mod tests { assert!(branch_needs_push(repo_dir, "origin", "main")); } - /// Helper: create a local repo with a bare remote and push main. - fn init_repo_with_remote(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) { - let repo_dir = dir.join("repo"); - let remote_dir = dir.join("remote.git"); - - Command::new("git") - .args(["init", "--bare"]) - .arg(&remote_dir) - .output() - .unwrap(); - Command::new("git") - .args(["init"]) - .arg(&repo_dir) - .output() - .unwrap(); - Command::new("git") - .args(["remote", "add", "origin"]) - .arg(&remote_dir) - .current_dir(&repo_dir) - .output() - .unwrap(); - Command::new("git") - .args([ - "-c", - "user.name=test", - "-c", - "user.email=test@test", - "commit", - "--allow-empty", - "-m", - "init", - ]) - .current_dir(&repo_dir) - .output() - .unwrap(); - Command::new("git") - .args(["branch", "-M", "main"]) - .current_dir(&repo_dir) - .output() - .unwrap(); - push_branch(&repo_dir, "origin", "main").unwrap(); - - (repo_dir, remote_dir) - } - #[test] fn metadata_branch_name_uses_meta_prefix() { assert_eq!(MetadataStore::branch_name("abc-123"), "fabro/meta/abc-123");