From 69ef57cd4a81c91b805a4cd069af1f327fc7ef7b Mon Sep 17 00:00:00 2001
From: "brynary-fabro[bot]"
<265161896+brynary-fabro[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 22:48:08 -0400
Subject: [PATCH] Add node-level model validation + missing catalog aliases
(#110)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This PR addresses two related issues that caused `fabro run` to fail at
runtime with an unhelpful LLM error when using model names like
`gpt-54`: the model catalog was missing hyphenated aliases, and the
validator had no rule to catch unknown model names on workflow nodes.
The catalog changes add `gpt-54`, `gpt-54-pro`, and `gpt-54-mini` as
recognized aliases alongside the existing `gpt54`-style variants for the
`gpt-5.4` model family. This makes the hyphenated form a valid input
that resolves correctly at both validation and runtime, and the updated
`fabro model list` output and insta snapshots reflect the expanded alias
columns. Three new alias-resolution unit tests confirm each mapping.
On the validation side, a new `NodeModelKnownRule` mirrors the existing
`StylesheetModelKnownRule` but iterates over graph nodes instead of
stylesheet declarations, emitting `Severity::Warning` diagnostics with
the relevant `node_id` when a node's `model` or `provider` attribute
doesn't match anything in the catalog. To avoid duplication, the inline
validation logic in `StylesheetModelKnownRule` was refactored into
shared `check_model_known` and `check_provider_known` helpers that both
rules now call. Five unit tests cover the valid-model, unknown-model,
alias, unknown-provider, and no-attribute cases. An unrelated dead-code
helper in `git.rs` was also removed as part of the cleanup.
### Fabro Details
Ran 11 stages in 12m 36s for $2.85
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 58s | – | 0 |
| preflight_lint | 10s | – | 0 |
| implement | 2m 56s | $1.13 | 0 |
| simplify_opus | 4m 12s | $1.13 | 0 |
| simplify_gpt | 0s | – | 0 |
| verify | 10s | – | 0 |
| fixup | 2m 27s | $0.59 | 0 |
| verify | 10s | – | 0 |
| fmt | 0s | – | 0 |
| **Total** | **12m 36s** | **$2.85** | **0** |
Ran ImplementAndSimplify.fabro (12 nodes and 15
edges)
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=success"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
Co-authored-by: Bryan Helmkamp
Co-authored-by: Claude Opus 4.6 (1M context)
---
.../fabro-cli/tests/cmd/model/bare.trycmd | 42 ++--
.../fabro-cli/tests/cmd/model/list.trycmd | 42 ++--
lib/crates/fabro-model/src/catalog.json | 6 +-
lib/crates/fabro-model/src/catalog.rs | 17 ++
lib/crates/fabro-validate/src/rules.rs | 188 +++++++++++++++---
lib/crates/fabro-workflows/src/git.rs | 45 -----
6 files changed, 220 insertions(+), 120 deletions(-)
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
-[0m [0m[0m[0m[1mMODEL [0m [0m[0m [0m[0m[0m[1mPROVIDER [0m [0m[0m [0m[0m[0m[1mALIASES [0m [0m[0m [0m[0m[0m[1mCONTEXT[0m [0m[0m [0m[0m[0m[1m COST[0m [0m[0m [0m[0m[0m[1m SPEED[0m [0m
-[0m[0m [0m[0m[0mclaude-opus-4-6 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0mopus, claude-opus [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $15.0 / $75.0[0m [0m[0m [0m[0m[0m 25 tok/s[0m [0m
-[0m[0m [0m[0m[0mclaude-sonnet-4-5 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0m [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $3.0 / $15.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
-[0m[0m [0m[0m[0mclaude-sonnet-4-6 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0msonnet, claude-sonnet[0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $3.0 / $15.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
-[0m[0m [0m[0m[0mclaude-haiku-4-5 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0mhaiku, claude-haiku [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $0.8 / $4.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.2 [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt5 [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 65 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5-mini [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt5-mini [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.2 / $2.0[0m [0m[0m [0m[0m[0m 70 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.2-codex [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0m [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.3-codex [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mcodex [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.3-codex-spark [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mcodex-spark [0m [0m[0m [0m[0m[0m 131k[0m [0m[0m [0m[0m[0m - / -[0m [0m[0m [0m[0m[0m1000 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.4 [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54 [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.5 / $15.0[0m [0m[0m [0m[0m[0m 70 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.4-pro [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54-pro [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m$30.0 / $180.0[0m [0m[0m [0m[0m[0m 20 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.4-mini [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54-mini [0m [0m[0m [0m[0m[0m 400k[0m [0m[0m [0m[0m[0m $0.8 / $4.5[0m [0m[0m [0m[0m[0m 140 tok/s[0m [0m
-[0m[0m [0m[0m[0mgemini-3.1-pro-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-pro [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.0 / $12.0[0m [0m[0m [0m[0m[0m 85 tok/s[0m [0m
-[0m[0m [0m[0m[0mgemini-3.1-pro-preview-customtools[0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-customtools [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.0 / $12.0[0m [0m[0m [0m[0m[0m 85 tok/s[0m [0m
-[0m[0m [0m[0m[0mgemini-3-flash-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-flash [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.5 / $3.0[0m [0m[0m [0m[0m[0m 150 tok/s[0m [0m
-[0m[0m [0m[0m[0mgemini-3.1-flash-lite-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-flash-lite [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.2 / $1.5[0m [0m[0m [0m[0m[0m 200 tok/s[0m [0m
-[0m[0m [0m[0m[0mkimi-k2.5 [0m [0m[0m [0m[0m[0mkimi [0m [0m[0m [0m[0m[0mkimi [0m [0m[0m [0m[0m[0m 262k[0m [0m[0m [0m[0m[0m $0.6 / $3.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
-[0m[0m [0m[0m[0mglm-4.7 [0m [0m[0m [0m[0m[0mzai [0m [0m[0m [0m[0m[0mglm, glm4 [0m [0m[0m [0m[0m[0m 203k[0m [0m[0m [0m[0m[0m $0.6 / $2.2[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
-[0m[0m [0m[0m[0mminimax-m2.5 [0m [0m[0m [0m[0m[0mminimax [0m [0m[0m [0m[0m[0mminimax [0m [0m[0m [0m[0m[0m 197k[0m [0m[0m [0m[0m[0m $0.3 / $1.2[0m [0m[0m [0m[0m[0m 45 tok/s[0m [0m
-[0m[0m [0m[0m[0mmercury-2 [0m [0m[0m [0m[0m[0minception[0m [0m[0m [0m[0m[0mmercury [0m [0m[0m [0m[0m[0m 131k[0m [0m[0m [0m[0m[0m $0.2 / $0.8[0m [0m[0m [0m[0m[0m1000 tok/s[0m [0m
+[0m [0m[0m[0m[1mMODEL [0m [0m[0m [0m[0m[0m[1mPROVIDER [0m [0m[0m [0m[0m[0m[1mALIASES [0m [0m[0m [0m[0m[0m[1mCONTEXT[0m [0m[0m [0m[0m[0m[1m COST[0m [0m[0m [0m[0m[0m[1m SPEED[0m [0m
+[0m[0m [0m[0m[0mclaude-opus-4-6 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0mopus, claude-opus [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $15.0 / $75.0[0m [0m[0m [0m[0m[0m 25 tok/s[0m [0m
+[0m[0m [0m[0m[0mclaude-sonnet-4-5 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0m [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $3.0 / $15.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
+[0m[0m [0m[0m[0mclaude-sonnet-4-6 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0msonnet, claude-sonnet [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $3.0 / $15.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
+[0m[0m [0m[0m[0mclaude-haiku-4-5 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0mhaiku, claude-haiku [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $0.8 / $4.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.2 [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt5 [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 65 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5-mini [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt5-mini [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.2 / $2.0[0m [0m[0m [0m[0m[0m 70 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.2-codex [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0m [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.3-codex [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mcodex [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.3-codex-spark [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mcodex-spark [0m [0m[0m [0m[0m[0m 131k[0m [0m[0m [0m[0m[0m - / -[0m [0m[0m [0m[0m[0m1000 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.4 [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54, gpt-54 [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.5 / $15.0[0m [0m[0m [0m[0m[0m 70 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.4-pro [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54-pro, gpt-54-pro [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m$30.0 / $180.0[0m [0m[0m [0m[0m[0m 20 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.4-mini [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54-mini, gpt-54-mini[0m [0m[0m [0m[0m[0m 400k[0m [0m[0m [0m[0m[0m $0.8 / $4.5[0m [0m[0m [0m[0m[0m 140 tok/s[0m [0m
+[0m[0m [0m[0m[0mgemini-3.1-pro-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-pro [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.0 / $12.0[0m [0m[0m [0m[0m[0m 85 tok/s[0m [0m
+[0m[0m [0m[0m[0mgemini-3.1-pro-preview-customtools[0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-customtools [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.0 / $12.0[0m [0m[0m [0m[0m[0m 85 tok/s[0m [0m
+[0m[0m [0m[0m[0mgemini-3-flash-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-flash [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.5 / $3.0[0m [0m[0m [0m[0m[0m 150 tok/s[0m [0m
+[0m[0m [0m[0m[0mgemini-3.1-flash-lite-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-flash-lite [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.2 / $1.5[0m [0m[0m [0m[0m[0m 200 tok/s[0m [0m
+[0m[0m [0m[0m[0mkimi-k2.5 [0m [0m[0m [0m[0m[0mkimi [0m [0m[0m [0m[0m[0mkimi [0m [0m[0m [0m[0m[0m 262k[0m [0m[0m [0m[0m[0m $0.6 / $3.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
+[0m[0m [0m[0m[0mglm-4.7 [0m [0m[0m [0m[0m[0mzai [0m [0m[0m [0m[0m[0mglm, glm4 [0m [0m[0m [0m[0m[0m 203k[0m [0m[0m [0m[0m[0m $0.6 / $2.2[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
+[0m[0m [0m[0m[0mminimax-m2.5 [0m [0m[0m [0m[0m[0mminimax [0m [0m[0m [0m[0m[0mminimax [0m [0m[0m [0m[0m[0m 197k[0m [0m[0m [0m[0m[0m $0.3 / $1.2[0m [0m[0m [0m[0m[0m 45 tok/s[0m [0m
+[0m[0m [0m[0m[0mmercury-2 [0m [0m[0m [0m[0m[0minception[0m [0m[0m [0m[0m[0mmercury [0m [0m[0m [0m[0m[0m 131k[0m [0m[0m [0m[0m[0m $0.2 / $0.8[0m [0m[0m [0m[0m[0m1000 tok/s[0m [0m
[0m
```
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
-[0m [0m[0m[0m[1mMODEL [0m [0m[0m [0m[0m[0m[1mPROVIDER [0m [0m[0m [0m[0m[0m[1mALIASES [0m [0m[0m [0m[0m[0m[1mCONTEXT[0m [0m[0m [0m[0m[0m[1m COST[0m [0m[0m [0m[0m[0m[1m SPEED[0m [0m
-[0m[0m [0m[0m[0mclaude-opus-4-6 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0mopus, claude-opus [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $15.0 / $75.0[0m [0m[0m [0m[0m[0m 25 tok/s[0m [0m
-[0m[0m [0m[0m[0mclaude-sonnet-4-5 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0m [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $3.0 / $15.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
-[0m[0m [0m[0m[0mclaude-sonnet-4-6 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0msonnet, claude-sonnet[0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $3.0 / $15.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
-[0m[0m [0m[0m[0mclaude-haiku-4-5 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0mhaiku, claude-haiku [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $0.8 / $4.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.2 [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt5 [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 65 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5-mini [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt5-mini [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.2 / $2.0[0m [0m[0m [0m[0m[0m 70 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.2-codex [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0m [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.3-codex [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mcodex [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.3-codex-spark [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mcodex-spark [0m [0m[0m [0m[0m[0m 131k[0m [0m[0m [0m[0m[0m - / -[0m [0m[0m [0m[0m[0m1000 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.4 [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54 [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.5 / $15.0[0m [0m[0m [0m[0m[0m 70 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.4-pro [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54-pro [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m$30.0 / $180.0[0m [0m[0m [0m[0m[0m 20 tok/s[0m [0m
-[0m[0m [0m[0m[0mgpt-5.4-mini [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54-mini [0m [0m[0m [0m[0m[0m 400k[0m [0m[0m [0m[0m[0m $0.8 / $4.5[0m [0m[0m [0m[0m[0m 140 tok/s[0m [0m
-[0m[0m [0m[0m[0mgemini-3.1-pro-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-pro [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.0 / $12.0[0m [0m[0m [0m[0m[0m 85 tok/s[0m [0m
-[0m[0m [0m[0m[0mgemini-3.1-pro-preview-customtools[0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-customtools [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.0 / $12.0[0m [0m[0m [0m[0m[0m 85 tok/s[0m [0m
-[0m[0m [0m[0m[0mgemini-3-flash-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-flash [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.5 / $3.0[0m [0m[0m [0m[0m[0m 150 tok/s[0m [0m
-[0m[0m [0m[0m[0mgemini-3.1-flash-lite-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-flash-lite [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.2 / $1.5[0m [0m[0m [0m[0m[0m 200 tok/s[0m [0m
-[0m[0m [0m[0m[0mkimi-k2.5 [0m [0m[0m [0m[0m[0mkimi [0m [0m[0m [0m[0m[0mkimi [0m [0m[0m [0m[0m[0m 262k[0m [0m[0m [0m[0m[0m $0.6 / $3.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
-[0m[0m [0m[0m[0mglm-4.7 [0m [0m[0m [0m[0m[0mzai [0m [0m[0m [0m[0m[0mglm, glm4 [0m [0m[0m [0m[0m[0m 203k[0m [0m[0m [0m[0m[0m $0.6 / $2.2[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
-[0m[0m [0m[0m[0mminimax-m2.5 [0m [0m[0m [0m[0m[0mminimax [0m [0m[0m [0m[0m[0mminimax [0m [0m[0m [0m[0m[0m 197k[0m [0m[0m [0m[0m[0m $0.3 / $1.2[0m [0m[0m [0m[0m[0m 45 tok/s[0m [0m
-[0m[0m [0m[0m[0mmercury-2 [0m [0m[0m [0m[0m[0minception[0m [0m[0m [0m[0m[0mmercury [0m [0m[0m [0m[0m[0m 131k[0m [0m[0m [0m[0m[0m $0.2 / $0.8[0m [0m[0m [0m[0m[0m1000 tok/s[0m [0m
+[0m [0m[0m[0m[1mMODEL [0m [0m[0m [0m[0m[0m[1mPROVIDER [0m [0m[0m [0m[0m[0m[1mALIASES [0m [0m[0m [0m[0m[0m[1mCONTEXT[0m [0m[0m [0m[0m[0m[1m COST[0m [0m[0m [0m[0m[0m[1m SPEED[0m [0m
+[0m[0m [0m[0m[0mclaude-opus-4-6 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0mopus, claude-opus [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $15.0 / $75.0[0m [0m[0m [0m[0m[0m 25 tok/s[0m [0m
+[0m[0m [0m[0m[0mclaude-sonnet-4-5 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0m [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $3.0 / $15.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
+[0m[0m [0m[0m[0mclaude-sonnet-4-6 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0msonnet, claude-sonnet [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $3.0 / $15.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
+[0m[0m [0m[0m[0mclaude-haiku-4-5 [0m [0m[0m [0m[0m[0manthropic[0m [0m[0m [0m[0m[0mhaiku, claude-haiku [0m [0m[0m [0m[0m[0m 200k[0m [0m[0m [0m[0m[0m $0.8 / $4.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.2 [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt5 [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 65 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5-mini [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt5-mini [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.2 / $2.0[0m [0m[0m [0m[0m[0m 70 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.2-codex [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0m [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.3-codex [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mcodex [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $1.8 / $14.0[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.3-codex-spark [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mcodex-spark [0m [0m[0m [0m[0m[0m 131k[0m [0m[0m [0m[0m[0m - / -[0m [0m[0m [0m[0m[0m1000 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.4 [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54, gpt-54 [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.5 / $15.0[0m [0m[0m [0m[0m[0m 70 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.4-pro [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54-pro, gpt-54-pro [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m$30.0 / $180.0[0m [0m[0m [0m[0m[0m 20 tok/s[0m [0m
+[0m[0m [0m[0m[0mgpt-5.4-mini [0m [0m[0m [0m[0m[0mopenai [0m [0m[0m [0m[0m[0mgpt54-mini, gpt-54-mini[0m [0m[0m [0m[0m[0m 400k[0m [0m[0m [0m[0m[0m $0.8 / $4.5[0m [0m[0m [0m[0m[0m 140 tok/s[0m [0m
+[0m[0m [0m[0m[0mgemini-3.1-pro-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-pro [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.0 / $12.0[0m [0m[0m [0m[0m[0m 85 tok/s[0m [0m
+[0m[0m [0m[0m[0mgemini-3.1-pro-preview-customtools[0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-customtools [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $2.0 / $12.0[0m [0m[0m [0m[0m[0m 85 tok/s[0m [0m
+[0m[0m [0m[0m[0mgemini-3-flash-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-flash [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.5 / $3.0[0m [0m[0m [0m[0m[0m 150 tok/s[0m [0m
+[0m[0m [0m[0m[0mgemini-3.1-flash-lite-preview [0m [0m[0m [0m[0m[0mgemini [0m [0m[0m [0m[0m[0mgemini-flash-lite [0m [0m[0m [0m[0m[0m 1m[0m [0m[0m [0m[0m[0m $0.2 / $1.5[0m [0m[0m [0m[0m[0m 200 tok/s[0m [0m
+[0m[0m [0m[0m[0mkimi-k2.5 [0m [0m[0m [0m[0m[0mkimi [0m [0m[0m [0m[0m[0mkimi [0m [0m[0m [0m[0m[0m 262k[0m [0m[0m [0m[0m[0m $0.6 / $3.0[0m [0m[0m [0m[0m[0m 50 tok/s[0m [0m
+[0m[0m [0m[0m[0mglm-4.7 [0m [0m[0m [0m[0m[0mzai [0m [0m[0m [0m[0m[0mglm, glm4 [0m [0m[0m [0m[0m[0m 203k[0m [0m[0m [0m[0m[0m $0.6 / $2.2[0m [0m[0m [0m[0m[0m 100 tok/s[0m [0m
+[0m[0m [0m[0m[0mminimax-m2.5 [0m [0m[0m [0m[0m[0mminimax [0m [0m[0m [0m[0m[0mminimax [0m [0m[0m [0m[0m[0m 197k[0m [0m[0m [0m[0m[0m $0.3 / $1.2[0m [0m[0m [0m[0m[0m 45 tok/s[0m [0m
+[0m[0m [0m[0m[0mmercury-2 [0m [0m[0m [0m[0m[0minception[0m [0m[0m [0m[0m[0mmercury [0m [0m[0m [0m[0m[0m 131k[0m [0m[0m [0m[0m[0m $0.2 / $0.8[0m [0m[0m [0m[0m[0m1000 tok/s[0m [0m
[0m
```
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");