diff --git a/litellm-rust/crates/model-catalog/src/catalog.rs b/litellm-rust/crates/model-catalog/src/catalog.rs index 7cffd26d61f..d502531d86c 100644 --- a/litellm-rust/crates/model-catalog/src/catalog.rs +++ b/litellm-rust/crates/model-catalog/src/catalog.rs @@ -189,13 +189,6 @@ impl Catalog { } let mut aliases: IndexMap = IndexMap::new(); - for (alias, target) in pointer_rows { - if entries.contains_key(&target) { - aliases.insert(alias, target); - } else { - problems.push(Error::from_key(&alias, Repr::AliasTargetMissing { target })); - } - } for (model, names) in alias_lists { for alias in names { if entries.contains_key(&alias) { @@ -213,6 +206,21 @@ impl Catalog { } } } + for (alias, target) in pointer_rows { + if !entries.contains_key(&target) { + problems.push(Error::from_key(&alias, Repr::AliasTargetMissing { target })); + } else if let Some(taken_by) = aliases.get(&alias) { + problems.push(Error::from_key( + &alias, + Repr::AliasTaken { + alias: alias.clone(), + taken_by: taken_by.clone(), + }, + )); + } else { + aliases.insert(alias, target); + } + } let fallback_generalizations = sections .iter() @@ -362,8 +370,7 @@ impl Catalog { let mut catalog = self; catalog.store.edit(|rows| { rows.entries.retain(|name, _| !rejected.contains(name)); - rows.aliases - .retain(|alias, owner| !rejected.contains(owner) && !rejected.contains(alias)); + rows.aliases.retain(|_, owner| !rejected.contains(owner)); }); catalog .sections @@ -436,7 +443,6 @@ impl Catalog { } } -/// One row as read: a model, or an `alias_of` pointer at one struct RowRead { model: Option, aliases: Option>, diff --git a/litellm-rust/crates/model-catalog/src/schema.rs b/litellm-rust/crates/model-catalog/src/schema.rs index 6fba985a8bd..60c755fe94f 100644 --- a/litellm-rust/crates/model-catalog/src/schema.rs +++ b/litellm-rust/crates/model-catalog/src/schema.rs @@ -104,9 +104,18 @@ pub fn registry_json_schema() -> Schema { "title".into(), "LiteLLM model prices and context window registry".into(), ); + definitions.insert( + "aliasRow".into(), + json!({ + "type": "object", + "required": ["alias_of"], + "properties": {"alias_of": {"type": "string"}}, + "additionalProperties": false, + }), + ); root.insert( "additionalProperties".into(), - json!({"$ref": "#/$defs/modelEntry"}), + json!({"anyOf": [{"$ref": "#/$defs/modelEntry"}, {"$ref": "#/$defs/aliasRow"}]}), ); root.insert("$defs".into(), Value::Object(definitions)); Schema::from(root.clone()) @@ -153,6 +162,7 @@ fn decorate_model_entry(schema: &mut Value) { let rates = json!({FLAT_KEY_PATTERN: {"type": "number", "minimum": 0}}); let object = schema.as_object_mut().unwrap(); object.insert("required".into(), json!(["litellm_provider"])); + object.insert("not".into(), json!({"required": ["alias_of"]})); object.insert("additionalProperties".into(), Value::Bool(true)); object.insert( "patternProperties".into(), diff --git a/litellm-rust/crates/model-catalog/tests/catalog.rs b/litellm-rust/crates/model-catalog/tests/catalog.rs index 3454b1f9a52..23d9beb363e 100644 --- a/litellm-rust/crates/model-catalog/tests/catalog.rs +++ b/litellm-rust/crates/model-catalog/tests/catalog.rs @@ -149,7 +149,7 @@ fn a_bad_entry_is_reported_by_model_and_key_and_kept_raw() { )] #[case::alias_of_claimed_by_a_list( json!({"Real": {"litellm_provider": "t"}, "real-latest": {"alias_of": "Real"}, "Other": {"litellm_provider": "t", "aliases": ["real-latest"]}}), - r#"Other: alias "real-latest" already belongs to Real"# + r#"real-latest: alias "real-latest" already belongs to Other"# )] #[case::aliases_not_a_list( json!({"a": {"litellm_provider": "t", "aliases": "bad"}}), @@ -450,3 +450,18 @@ fn a_dangling_alias_of_row_is_rejected_and_dropped_from_the_written_document() { assert!(catalog.lookup("ghost-latest").is_none()); assert_eq!(written(&catalog), r#"{"Real":{"litellm_provider":"t"}}"#); } + +#[rstest] +fn a_pointer_row_that_repeats_a_listed_alias_is_dropped_and_the_model_is_kept() { + let parsed = loaded(json!({ + "Real": {"litellm_provider": "t", "aliases": ["real-latest"]}, + "real-latest": {"alias_of": "Real"} + })); + let (catalog, removed) = parsed.value.without_rejected(&parsed.errors); + assert_eq!(removed.len(), 1); + assert_eq!(catalog.lookup("real-latest").unwrap().canonical_key, "Real"); + assert_eq!( + written(&catalog), + r#"{"Real":{"litellm_provider":"t","aliases":["real-latest"]}}"# + ); +} diff --git a/litellm-rust/crates/model-catalog/tests/schema.rs b/litellm-rust/crates/model-catalog/tests/schema.rs index 464cf069cac..5fe5832cb58 100644 --- a/litellm-rust/crates/model-catalog/tests/schema.rs +++ b/litellm-rust/crates/model-catalog/tests/schema.rs @@ -35,6 +35,7 @@ fn generated_registry_schema_validates_checked_in_catalog() { #[case(json!({"example": {"litellm_provider": "test"}}))] #[case(json!({"example": {"litellm_provider": "test", "future_field": true}}))] #[case(json!({"sample_spec": {"litellm_provider": "placeholder"}}))] +#[case(json!({"example": {"litellm_provider": "test"}, "example-latest": {"alias_of": "example"}}))] fn generated_registry_schema_keeps_reader_compatibility(#[case] document: Value) { assert!(registry_validator().is_valid(&document)); } @@ -54,6 +55,9 @@ fn generated_registry_schema_keeps_reader_compatibility(#[case] document: Value) #[case::empty_windows(json!({"litellm_provider": "test", "off_peak_pricing": {"windows": []}}))] #[case::invalid_weekday(json!({"litellm_provider": "test", "off_peak_pricing": {"windows": [{"hours_utc": "00:00-01:00", "weekdays": [0]}]}}))] #[case::invalid_aliases(json!({"litellm_provider": "test", "aliases": "wrong"}))] +#[case::alias_of_on_a_model(json!({"litellm_provider": "test", "alias_of": "other"}))] +#[case::alias_row_with_other_fields(json!({"alias_of": "other", "mode": "chat"}))] +#[case::alias_of_not_a_string(json!({"alias_of": 4}))] #[case::non_object_model(json!(4))] fn generated_registry_schema_rejects_invalid_entries(#[case] entry: Value) { assert!(!registry_validator().is_valid(&json!({"example": entry}))); diff --git a/litellm/litellm_core_utils/get_model_cost_map.py b/litellm/litellm_core_utils/get_model_cost_map.py index b0df3c9bf38..e8feeb75351 100644 --- a/litellm/litellm_core_utils/get_model_cost_map.py +++ b/litellm/litellm_core_utils/get_model_cost_map.py @@ -606,7 +606,7 @@ def _expand_model_aliases(model_cost: dict[str, dict[str, object]]) -> dict[str, for model_name, model_info in model_cost.items(): aliases: object = model_info.get("aliases") - if aliases is None: + if aliases is None or model_name in pointer_rows: continue keys_with_aliases.append(model_name) if not isinstance(aliases, list): diff --git a/tests/unit/litellm_core_utils/test_get_model_cost_map.py b/tests/unit/litellm_core_utils/test_get_model_cost_map.py index 509a777d137..1391fff257f 100644 --- a/tests/unit/litellm_core_utils/test_get_model_cost_map.py +++ b/tests/unit/litellm_core_utils/test_get_model_cost_map.py @@ -130,12 +130,15 @@ def test_finalize_pops_key_and_installs_rules(): set_fallback_generalizations(previous) -def test_alias_of_rows_are_served_as_their_target_and_dangling_ones_are_dropped(caplog) -> None: +def test_alias_of_rows_are_served_as_their_target_and_dangling_ones_are_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: raw: Final = { "real": {"litellm_provider": "openai", "mode": "chat", "input_cost_per_token": 1e-6}, "real-latest": {"alias_of": "real"}, "ghost-latest": {"alias_of": "ghost"}, "chain-latest": {"alias_of": "real-latest"}, + "listed-latest": {"alias_of": "missing", "aliases": ["extra"]}, } with caplog.at_level("WARNING"): finalized: Final = _finalize_model_cost_map(raw) @@ -143,7 +146,9 @@ def test_alias_of_rows_are_served_as_their_target_and_dangling_ones_are_dropped( assert "alias_of" not in finalized["real"] assert set(finalized) == {"real", "real-latest"} assert finalized["real-latest"]["litellm_provider"] == "openai" - assert {"ghost-latest", "chain-latest"} <= {record.getMessage().split("'")[1] for record in caplog.records} + assert {"ghost-latest", "chain-latest", "listed-latest"} == { + record.getMessage().split("'")[1] for record in caplog.records + } def test_finalize_with_no_block_clears_rules():