diff --git a/lib/apps/fabro-cli/src/commands/install.rs b/lib/apps/fabro-cli/src/commands/install.rs index 37449bb7a..f7817bf2f 100644 --- a/lib/apps/fabro-cli/src/commands/install.rs +++ b/lib/apps/fabro-cli/src/commands/install.rs @@ -83,22 +83,22 @@ fn supports_install_api_key(provider: &CatalogProvider) -> bool { fn install_llm_provider_ids(catalog: &Catalog) -> Vec { catalog::listed_providers(catalog) - .iter() - .filter(|entry| supports_install_api_key(entry.provider)) - .map(|entry| entry.provider.id().clone()) + .into_iter() + .filter(|provider| supports_install_api_key(provider)) + .map(|provider| provider.id().clone()) .collect() } fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String { catalog::provider(catalog, provider.as_str()) - .map(|entry| fabro_auth::secret_names(entry.provider).join(" / ")) + .map(|provider| fabro_auth::secret_names(provider).join(" / ")) .filter(|label| !label.is_empty()) .unwrap_or_else(|| "API_KEY".to_string()) } fn provider_vault_secret_name(provider: &ProviderId, catalog: &Catalog) -> String { catalog::provider(catalog, provider.as_str()) - .and_then(|entry| fabro_auth::expected_secret_name(entry.provider)) + .and_then(fabro_auth::expected_secret_name) .unwrap_or_else(|| format!("{}_API_KEY", provider.to_string().to_uppercase())) } diff --git a/lib/apps/fabro-cli/src/shared/provider_auth.rs b/lib/apps/fabro-cli/src/shared/provider_auth.rs index 793f92875..6de3d0652 100644 --- a/lib/apps/fabro-cli/src/shared/provider_auth.rs +++ b/lib/apps/fabro-cli/src/shared/provider_auth.rs @@ -61,7 +61,7 @@ fn default_catalog_for_provider_auth() -> Arc { pub(crate) fn provider_display_name(provider: &ProviderId, catalog: &Catalog) -> String { catalog::provider(catalog, provider.as_str()).map_or_else( || provider.to_string(), - |entry| entry.provider.display_name().to_string(), + |provider| provider.display_name().to_string(), ) } @@ -69,14 +69,14 @@ fn api_key_catalog_provider<'a>( provider: &ProviderId, catalog: &'a Catalog, ) -> Result<&'a CatalogProvider> { - let entry = catalog::provider(catalog, provider.as_str()) + let provider = catalog::provider(catalog, provider.as_str()) .with_context(|| format!("provider '{provider}' is not configured in the model catalog"))?; anyhow::ensure!( - fabro_auth::accepts_api_key(entry.provider), + fabro_auth::accepts_api_key(provider), "provider '{}' does not define an API-key credential path", - entry.provider.id() + provider.id() ); - Ok(entry.provider) + Ok(provider) } pub(crate) async fn validate_api_key( @@ -368,8 +368,6 @@ async fn await_user_response_from_source( #[cfg(test)] mod tests { - use fabro_types::catalog_policy; - use super::*; #[test] @@ -385,8 +383,7 @@ mod tests { ProviderId::new("inception"), ] { let provider = api_key_catalog_provider(&provider, &catalog).unwrap(); - let policy = catalog_policy::provider_policy(provider); - let url = policy.api_key_url.as_deref().unwrap_or_default(); + let url = provider.api_key_url().unwrap_or_default(); assert!(!url.is_empty(), "{} has empty URL", provider.id()); assert!(url.starts_with("https://"), "{} URL: {url}", provider.id()); } diff --git a/lib/apps/fabro-cli/tests/it/cmd/exec.rs b/lib/apps/fabro-cli/tests/it/cmd/exec.rs index c1b6be4a2..7f2d74720 100644 --- a/lib/apps/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/apps/fabro-cli/tests/it/cmd/exec.rs @@ -333,7 +333,7 @@ fn exec_accepts_configured_custom_provider_from_settings() { let context = test_context!(); context.write_home( ".fabro/settings.toml", - "_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.fabro]\nagent_profile = \"openai\"\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n", + "_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.agent]\nprofile = \"openai\"\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n", ); let mut cmd = context.exec_cmd(); @@ -398,7 +398,7 @@ fn exec_server_target_accepts_configured_custom_provider_from_settings() { let context = test_context!(); context.write_home( ".fabro/settings.toml", - "_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.fabro]\nagent_profile = \"openai\"\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n", + "_version = 1\n\n[llm.providers.acme-aws]\ndisplay_name = \"Acme AWS\"\nadapter = \"openai-compatible\"\ncodec = \"openai-chat\"\nbase_url = \"https://bedrock.example.invalid/v1\"\nauth = { type = \"bearer\" }\nallow_passthrough = true\n\n[llm.providers.acme-aws.metadata.agent]\nprofile = \"openai\"\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n", ); let server = MockServer::start(); server.mock(|when, then| { diff --git a/lib/apps/fabro-server/src/install.rs b/lib/apps/fabro-server/src/install.rs index 9e812929c..63cc592b1 100644 --- a/lib/apps/fabro-server/src/install.rs +++ b/lib/apps/fabro-server/src/install.rs @@ -846,14 +846,14 @@ async fn put_install_llm( } fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogProvider, String> { - let entry = llm_catalog::provider(&INSTALL_CATALOG, provider.as_str()) + let catalog_provider = llm_catalog::provider(&INSTALL_CATALOG, provider.as_str()) .ok_or_else(|| format!("provider '{provider}' is not configured in the model catalog"))?; - if fabro_auth::accepts_api_key(entry.provider) { - Ok(entry.provider) + if fabro_auth::accepts_api_key(catalog_provider) { + Ok(catalog_provider) } else { Err(format!( "provider '{}' does not define an API-key credential path", - entry.provider.id() + catalog_provider.id() )) } } diff --git a/lib/apps/fabro-server/src/run_manifest.rs b/lib/apps/fabro-server/src/run_manifest.rs index 104a6f4cf..6b94bfc89 100644 --- a/lib/apps/fabro-server/src/run_manifest.rs +++ b/lib/apps/fabro-server/src/run_manifest.rs @@ -1763,7 +1763,7 @@ mod tests { fn openrouter_catalog() -> Catalog { fabro_llm::test_support::test_catalog_with_overlay( - "[providers.openrouter.metadata.fabro]\nenabled = true\n", + "[providers.openrouter]\nenabled = true\n", ) } @@ -1883,9 +1883,8 @@ base_url = "{moonshot_url}" [providers.openrouter] base_url = "{openrouter_url}" - -[providers.openrouter.metadata.fabro] enabled = true + "# )) .vault_entries([ @@ -3029,9 +3028,8 @@ base_url = "https://api.acme.test/v1" auth = { type = "bearer" } default_model = "acme-large" -[providers.acme.metadata.fabro] -agent_profile = "openai" -credentials = ["env:ACME_API_KEY"] +[providers.acme.metadata.agent] +profile = "openai" [providers.acme.models."acme-large"] display_name = "Acme Large" diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 7056e511e..35203846b 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -909,13 +909,13 @@ fn api_model_on_eligible( eligible: &std::collections::HashSet, ) -> Option<(ProviderId, String)> { catalog::enabled_providers(catalog) - .iter() - .filter(|entry| eligible.contains(entry.provider.id())) - .find_map(|entry| { - catalog::provider_models(entry.provider) + .into_iter() + .filter(|provider| eligible.contains(provider.id())) + .find_map(|provider| { + catalog::provider_models(provider) .into_iter() .find(|model| model.model.api_model() == api_model) - .map(|model| (entry.provider.id().clone(), model.model.id().to_string())) + .map(|model| (provider.id().clone(), model.model.id().to_string())) }) } @@ -1554,9 +1554,8 @@ default_model = "gpt-5.6-sol" [providers.openrouter] default_model = "gpt-5.6-sol" - -[providers.openrouter.metadata.fabro] enabled = true + "#, ) } diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 4ee760198..68b2503a7 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -323,7 +323,10 @@ fn openai_responses_payload(text: &str) -> serde_json::Value { /// An operator-defined OpenAI-compatible provider `acme` offering one model, /// `acme-large`, with `credential` (`env:NAME` or `vault:NAME`). -fn acme_overlay(base_url: &str, credential: &str) -> String { +/// An operator-defined provider. Its API key is `ACME_API_KEY`, the name +/// lithos derives from the provider id, whether it lives in the vault or the +/// environment. +fn acme_overlay(base_url: &str) -> String { format!( r#" [providers.acme] @@ -335,21 +338,18 @@ auth = {{ type = "bearer" }} priority = 120 default_model = "acme-large" -[providers.acme.metadata.fabro] -agent_profile = "openai" -credentials = [{credential}] +[providers.acme.metadata.agent] +profile = "openai" [providers.acme.models."acme-large"] display_name = "Acme Large" api_model = "acme-large" limits = {{ context_tokens = 128000, max_output_tokens = 8192 }} capabilities = {{ text = true, tools = true }} - -[providers.acme.models."acme-large".metadata.fabro] probe = true + "#, base_url = toml::Value::String(base_url.to_string()), - credential = toml::Value::String(credential.to_string()), ) } @@ -5591,10 +5591,7 @@ async fn validate_endpoint_returns_workflow_summary_without_preflight_checks() { #[tokio::test] async fn validate_endpoint_uses_app_state_catalog_for_model_diagnostics() { let state = TestAppStateBuilder::new() - .llm_overlay_toml(&acme_overlay( - "https://api.acme.test/v1", - "env:ACME_API_KEY", - )) + .llm_overlay_toml(&acme_overlay("https://api.acme.test/v1")) .build(); let app = crate::test_support::build_test_router(state); let dot = r#"digraph Test { @@ -8826,9 +8823,8 @@ auth = {{ type = "bearer" }} priority = 120 default_model = "portable-model" -[providers.direct.metadata.fabro] -agent_profile = "openai" -credentials = ["vault:DIRECT_API_KEY"] +[providers.direct.metadata.agent] +profile = "openai" [providers.direct.models.portable-model] display_name = "Portable (direct)" @@ -8846,9 +8842,8 @@ auth = {{ type = "bearer" }} priority = 110 default_model = "portable-model" -[providers.aggregator.metadata.fabro] -agent_profile = "openai" -credentials = ["vault:AGGREGATOR_API_KEY"] +[providers.aggregator.metadata.agent] +profile = "openai" [providers.aggregator.models.portable-model] display_name = "Portable (aggregator)" @@ -9011,9 +9006,8 @@ auth = {{ type = "bearer" }} priority = 120 default_model = "acme-reasoner" -[providers.acme.metadata.fabro] -agent_profile = "openai" -credentials = ["vault:ACME_API_KEY"] +[providers.acme.metadata.agent] +profile = "openai" [providers.acme.models.acme-reasoner] display_name = "Acme Reasoner" @@ -9080,7 +9074,7 @@ async fn test_provider_credentials_uses_app_state_catalog() { "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} })); }); - let overlay = acme_overlay(&upstream.base_url(), "vault:ACME_API_KEY"); + let overlay = acme_overlay(&upstream.base_url()); let state = TestAppStateBuilder::new() .runtime_settings(default_test_server_settings(), RunLayer::default()) .max_concurrent_runs(5) @@ -9200,7 +9194,7 @@ async fn list_models_marks_configured_true_when_provider_has_credential_material #[tokio::test] async fn list_models_marks_configured_false_when_provider_cannot_register() { - let overlay = acme_overlay("https://api.acme.test/v1", "env:ACME_API_KEY"); + let overlay = acme_overlay("https://api.acme.test/v1"); let state = TestAppStateBuilder::new() .runtime_settings(default_test_server_settings(), RunLayer::default()) .max_concurrent_runs(5) @@ -9270,7 +9264,7 @@ async fn list_models_unknown_provider_returns_empty_page() { #[tokio::test] async fn list_models_uses_app_state_catalog_overrides() { - let overlay = acme_overlay("https://api.acme.test/v1", "env:ACME_API_KEY"); + let overlay = acme_overlay("https://api.acme.test/v1"); let state = TestAppStateBuilder::new() .llm_overlay_toml(&overlay) .build(); @@ -9338,9 +9332,7 @@ async fn list_providers_marks_configured_per_provider_and_omits_secrets() { // this exact provider, not merely be populated. let catalog = state_test_catalog(); let expected_model_count = fabro_llm::catalog::provider_models( - fabro_llm::catalog::provider(&catalog, "anthropic") - .expect("anthropic should be listed") - .provider, + fabro_llm::catalog::provider(&catalog, "anthropic").expect("anthropic should be listed"), ) .len(); assert_eq!( @@ -9557,7 +9549,7 @@ async fn test_providers_auth_issue_returns_error_without_upstream_call() { async fn test_providers_registration_issue_returns_error_without_probe() { // An adapter lithos does not ship cannot be built, so the provider is // configured (it has a vault key) yet unavailable. - let overlay = acme_overlay("https://api.acme.test/v1", "vault:ACME_API_KEY").replace( + let overlay = acme_overlay("https://api.acme.test/v1").replace( "adapter = \"openai-compatible\"", "adapter = \"not-an-adapter\"", ); @@ -9639,16 +9631,11 @@ auth = {{ type = "bearer" }} priority = 50 default_model = "zeta-probe" -[providers.zeta.metadata.fabro] -credentials = ["vault:ZETA_API_KEY"] - [providers.zeta.models.zeta-probe] display_name = "Zeta Probe" api_model = "zeta-probe" limits = {{ context_tokens = 128000, max_output_tokens = 8192 }} capabilities = {{ text = true, tools = true }} - -[providers.zeta.models.zeta-probe.metadata.fabro] probe = true [providers.alpha] @@ -9660,17 +9647,13 @@ auth = {{ type = "bearer" }} priority = 40 default_model = "alpha-probe" -[providers.alpha.metadata.fabro] -credentials = ["vault:ALPHA_API_KEY"] - [providers.alpha.models.alpha-probe] display_name = "Alpha Probe" api_model = "alpha-probe" limits = {{ context_tokens = 128000, max_output_tokens = 8192 }} capabilities = {{ text = true, tools = true }} - -[providers.alpha.models.alpha-probe.metadata.fabro] probe = true + "#, base_url = toml::Value::String(server.base_url()), ); @@ -18678,7 +18661,7 @@ async fn create_completion_default_model_uses_app_state_catalog() { .header("content-type", "application/json") .json_body(json!({"error": {"message": "expected test failure"}})); }); - let overlay = acme_overlay(&upstream.base_url(), "vault:ACME_API_KEY"); + let overlay = acme_overlay(&upstream.base_url()); let state = TestAppStateBuilder::new() .llm_overlay_toml(&overlay) .vault_entries([("ACME_API_KEY", "acme-test-key")]) diff --git a/lib/components/fabro-agent/src/agent_profile.rs b/lib/components/fabro-agent/src/agent_profile.rs index 06b74031c..a8021f886 100644 --- a/lib/components/fabro-agent/src/agent_profile.rs +++ b/lib/components/fabro-agent/src/agent_profile.rs @@ -40,7 +40,7 @@ pub trait AgentProfile: Send + Sync { fn knowledge_cutoff(&self) -> Option { self.catalog_model() - .and_then(|entry| entry.policy.knowledge_cutoff) + .and_then(|entry| entry.model.knowledge_cutoff().map(str::to_string)) } /// The catalog row for this profile's route, when the catalog knows it. diff --git a/lib/components/fabro-agent/src/cli.rs b/lib/components/fabro-agent/src/cli.rs index a9d3d5c1e..84f257831 100644 --- a/lib/components/fabro-agent/src/cli.rs +++ b/lib/components/fabro-agent/src/cli.rs @@ -425,8 +425,8 @@ fn cli_client_options(args: &AgentArgs, styles: &'static Styles) -> ClientOption } } -/// The catalog the standalone agent runs against: lithos built-ins, Fabro -/// policy, and the operator's `[llm]` overlay from the active settings file. +/// The catalog the standalone agent runs against: the lithos built-ins and +/// the operator's `[llm]` overlay from the active settings file. #[expect( clippy::disallowed_methods, reason = "Standalone agent honors OPENAI_BASE_URL from the process environment." @@ -958,19 +958,18 @@ base_url = "https://example.invalid/v1" auth = { type = "bearer" } default_model = "acme-aws-claude" -[providers.acme-aws.metadata.fabro] -agent_profile = "openai" -credentials = ["env:ACME_API_KEY"] +[providers.acme-aws.metadata.agent] +profile = "openai" [providers.acme-aws.models.acme-aws-claude] display_name = "Acme AWS Claude" api_model = "acme-aws-claude" limits = { context_tokens = 1000, max_output_tokens = 500 } capabilities = { text = true, tools = true } - -[providers.acme-aws.models.acme-aws-claude.metadata.fabro] family = "claude" -agent_profile = "anthropic" + +[providers.acme-aws.models.acme-aws-claude.metadata.agent] +profile = "anthropic" "#; /// The same provider with no models, so its default comes from the @@ -984,9 +983,8 @@ base_url = "https://example.invalid/v1" auth = { type = "bearer" } allow_passthrough = true -[providers.acme-aws.metadata.fabro] -agent_profile = "openai" -credentials = ["env:ACME_API_KEY"] +[providers.acme-aws.metadata.agent] +profile = "openai" "#; fn acme_catalog() -> Catalog { diff --git a/lib/components/fabro-agent/src/profiles/gpt56.rs b/lib/components/fabro-agent/src/profiles/gpt56.rs index 75d299c56..2bb2dd1c4 100644 --- a/lib/components/fabro-agent/src/profiles/gpt56.rs +++ b/lib/components/fabro-agent/src/profiles/gpt56.rs @@ -237,7 +237,7 @@ mod tests { /// OpenRouter ships disabled in the built-in catalog. fn catalog_with_openrouter() -> Arc { Arc::new(test_catalog_with_overlay( - "[providers.openrouter.metadata.fabro] + "[providers.openrouter] enabled = true ", )) diff --git a/lib/components/fabro-agent/src/profiles/kimi.rs b/lib/components/fabro-agent/src/profiles/kimi.rs index e24dc5c11..ee2dc4d87 100644 --- a/lib/components/fabro-agent/src/profiles/kimi.rs +++ b/lib/components/fabro-agent/src/profiles/kimi.rs @@ -172,7 +172,7 @@ mod tests { /// selectable. Enable it the way they would, to observe gateway routing. fn catalog_with_openrouter() -> Arc { Arc::new(test_catalog_with_overlay( - "[providers.openrouter.metadata.fabro] + "[providers.openrouter] enabled = true ", )) diff --git a/lib/components/fabro-agent/src/profiles/mod.rs b/lib/components/fabro-agent/src/profiles/mod.rs index ed5204331..27905fa86 100644 --- a/lib/components/fabro-agent/src/profiles/mod.rs +++ b/lib/components/fabro-agent/src/profiles/mod.rs @@ -467,7 +467,7 @@ mod tests { /// OpenRouter ships disabled, so an operator opts in before its models are /// selectable. - const OPENROUTER_ENABLED: &str = "[providers.openrouter.metadata.fabro]\nenabled = true\n"; + const OPENROUTER_ENABLED: &str = "[providers.openrouter]\nenabled = true\n"; fn native_tool_options( profile_kind: AgentProfileKind, diff --git a/lib/components/fabro-agent/tests/it/guardrails.rs b/lib/components/fabro-agent/tests/it/guardrails.rs index 4f8c41de3..e84599131 100644 --- a/lib/components/fabro-agent/tests/it/guardrails.rs +++ b/lib/components/fabro-agent/tests/it/guardrails.rs @@ -8,7 +8,7 @@ use fabro_llm::test_support::test_catalog; fn profile_context_window_matches_catalog_for_default_models() { let catalog = Arc::new(test_catalog()); for provider in catalog::listed_providers(&catalog) { - let provider_id = provider.provider.id().clone(); + let provider_id = provider.id().clone(); let Some(default) = catalog::default_model(&catalog, provider_id.as_str()) else { // Deployment-defined providers (LiteLLM, Modal, Ollama) carry no // built-in default model. diff --git a/lib/components/fabro-agent/tests/it/parity_matrix.rs b/lib/components/fabro-agent/tests/it/parity_matrix.rs index 4c8c7d416..f85b0b5f2 100644 --- a/lib/components/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/components/fabro-agent/tests/it/parity_matrix.rs @@ -168,7 +168,7 @@ async fn make_twin_client(twin: &OpenAiTwinOptions) -> Client { /// resolves the OpenAI-compatible codec the twin speaks. fn litellm_twin_overlay(base_url: &str) -> String { format!( - "[providers.litellm]\nbase_url = {}\n\n[providers.litellm.metadata.fabro]\nenabled = true\n", + "[providers.litellm]\nbase_url = {}\nenabled = true\n", toml::Value::String(base_url.to_string()) ) } diff --git a/lib/components/fabro-llm/catalog/fabro-policy.toml b/lib/components/fabro-llm/catalog/fabro-policy.toml deleted file mode 100644 index 86cb4ab3c..000000000 --- a/lib/components/fabro-llm/catalog/fabro-policy.toml +++ /dev/null @@ -1,883 +0,0 @@ -# Fabro's policy layer over the lithos-llm built-in catalog. -# -# Applied after the built-ins and before operator `[llm]` overlays. Everything -# under `metadata.fabro` is Fabro policy that lithos carries verbatim: -# -# - `enabled`: whether Fabro offers the provider or model. -# - `credentials`: ordered credential references (`env:NAME`, `vault:NAME`, -# `aws_sigv4`); the first that resolves wins. -# - `extra_headers`: request headers, literal or `{{ secrets.NAME }}`. -# - `agent_profile`, `family`, `small_default`, `probe`: agent and selection -# policy read by the Fabro model resolver. -# -# Modal's default model comes from a deployment template, not this layer. -# GPT-OSS is excluded at user request. This layer cannot restore that family. -schema_version = 1 - -[providers."anthropic"] -priority = 100 -default_model = "claude-sonnet-5" - -[providers."anthropic".metadata.fabro] -api_key_url = "https://console.anthropic.com/settings/keys" -credentials = ["env:ANTHROPIC_API_KEY", "vault:ANTHROPIC_API_KEY"] -enabled = true - -[providers."anthropic".models."claude-fable-5".metadata.fabro] -family = "claude-5" -agent_profile = "claude-5" -small_default = false -probe = false - -[providers."anthropic".models."claude-opus-5".metadata.fabro] -family = "claude-5" -agent_profile = "claude-5" -small_default = false -probe = false - -[providers."anthropic".models."claude-sonnet-5".metadata.fabro] -family = "claude-5" -agent_profile = "claude-5" -small_default = false -probe = false - -[providers."anthropic".models."claude-opus-4.8".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = false -probe = false - -[providers."anthropic".models."claude-opus-4.7".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = false -probe = false - -[providers."anthropic".models."claude-opus-4.6".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = false -probe = false - -[providers."anthropic".models."claude-sonnet-4.5".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = false -probe = false - -[providers."anthropic".models."claude-sonnet-4.6".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = false -probe = false - -[providers."anthropic".models."claude-haiku-4.5".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = true -probe = true - -[providers."bedrock-openai"] -priority = 19 -default_model = "gpt-5.5" - -[providers."bedrock-openai".metadata.fabro] -api_key_url = "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" -credentials = ["env:AWS_BEARER_TOKEN_BEDROCK", "env:BEDROCK_API_KEY", "vault:AWS_BEARER_TOKEN_BEDROCK", "vault:BEDROCK_API_KEY"] -enabled = false - -[providers."bedrock-openai".models."gpt-5.5".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock-openai".models."gpt-5.4".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock"] -priority = 20 -default_model = "claude-sonnet-5" - -[providers."bedrock".metadata.fabro] -api_key_url = "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" -credentials = ["env:AWS_BEARER_TOKEN_BEDROCK", "env:BEDROCK_API_KEY", "vault:AWS_BEARER_TOKEN_BEDROCK", "vault:BEDROCK_API_KEY", "aws_sigv4"] -enabled = false - -[providers."bedrock".models."anthropic.claude-sonnet-4-6".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = false -probe = false - -[providers."bedrock".models."claude-opus-4-8".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = false -probe = false - -[providers."bedrock".models."claude-haiku-4-5".metadata.fabro] -family = "claude-4" -agent_profile = "anthropic" -small_default = true -probe = false - -[providers."bedrock".models."nova-2-lite".metadata.fabro] -family = "nova-2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."llama-4-maverick".metadata.fabro] -family = "llama-4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."mistral-large-3".metadata.fabro] -family = "mistral-large" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."devstral-2".metadata.fabro] -family = "devstral" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."deepseek-v3.2".metadata.fabro] -family = "deepseek-v3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."kimi-k2.5".metadata.fabro] -family = "kimi-k2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."glm-5".metadata.fabro] -family = "glm" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."minimax-m2.5".metadata.fabro] -family = "minimax-m2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."nemotron-3-super".metadata.fabro] -family = "nemotron-3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."bedrock".models."claude-fable-5".metadata.fabro] -family = "claude-5" -agent_profile = "claude-5" -small_default = false -probe = false - -[providers."bedrock".models."claude-sonnet-5".metadata.fabro] -family = "claude-5" -agent_profile = "claude-5" -small_default = false -probe = false - -[providers."deepseek"] -priority = 75 -default_model = "deepseek-v4-flash" - -[providers."deepseek".metadata.fabro] -api_key_url = "https://platform.deepseek.com/api_keys" -credentials = ["env:DEEPSEEK_API_KEY", "vault:DEEPSEEK_API_KEY"] -enabled = true - -[providers."deepseek".models."deepseek-v4-flash".metadata.fabro] -reasoning_by_default = true -family = "deepseek-v4" -agent_profile = "openai" -small_default = true -probe = true - -[providers."deepseek".models."deepseek-v4-pro".metadata.fabro] -reasoning_by_default = true -family = "deepseek-v4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."fireworks"] -priority = 30 -default_model = "kimi-k2.7-code" - -[providers."fireworks".metadata.fabro] -api_key_url = "https://app.fireworks.ai/settings/users/api-keys" -credentials = ["env:FIREWORKS_API_KEY", "vault:FIREWORKS_API_KEY"] -enabled = false - -# Moonshot documents K3 as always reasoning with low, high, and max effort; -# lithos leaves the levels unverified, so Fabro records them here. -[providers."fireworks".models."kimi-k3"] -capabilities = { reasoning_effort = { minimal = false, low = true, medium = false, high = true, xhigh = false, max = true } } - -[providers."fireworks".models."kimi-k3".metadata.fabro] -family = "kimi-k3" -agent_profile = "kimi" -small_default = false -probe = false - -[providers."fireworks".models."kimi-k3-fast".metadata.fabro] -family = "kimi-k3" -agent_profile = "kimi" -small_default = false -probe = false - -[providers."fireworks".models."kimi-k2.7-code".metadata.fabro] -family = "kimi-k2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."fireworks".models."kimi-k2.6".metadata.fabro] -family = "kimi-k2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."fireworks".models."deepseek-v4-pro".metadata.fabro] -reasoning_by_default = true -family = "deepseek-v4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."fireworks".models."deepseek-v4-flash".metadata.fabro] -reasoning_by_default = true -family = "deepseek-v4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."fireworks".models."glm-5.2".metadata.fabro] -family = "glm-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."fireworks".models."minimax-m2.7".metadata.fabro] -family = "minimax-m2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."fireworks".models."qwen3.7-plus".metadata.fabro] -family = "qwen3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."gemini"] -priority = 80 -default_model = "gemini-3.5-flash" - -[providers."gemini".metadata.fabro] -api_key_url = "https://aistudio.google.com/apikey" -credentials = ["env:GEMINI_API_KEY", "env:GOOGLE_API_KEY", "vault:GEMINI_API_KEY"] -enabled = true - -[providers."gemini".models."gemini-3.1-pro-preview".metadata.fabro] -family = "gemini-3" -agent_profile = "gemini" -small_default = false -probe = false - -[providers."gemini".models."gemini-3.1-pro-preview-customtools".metadata.fabro] -family = "gemini-3" -agent_profile = "gemini" -small_default = false -probe = false - -[providers."gemini".models."gemini-3.5-flash".metadata.fabro] -family = "gemini-3" -agent_profile = "gemini" -small_default = false -probe = false - -[providers."gemini".models."gemini-3-flash-preview".metadata.fabro] -family = "gemini-3" -agent_profile = "gemini" -small_default = false -probe = false - -[providers."gemini".models."gemini-3.1-flash-lite".metadata.fabro] -family = "gemini-3" -agent_profile = "gemini" -small_default = true -probe = false - -[providers."inception"] -priority = 40 -default_model = "mercury-2" - -[providers."inception".metadata.fabro] -api_key_url = "https://console.inceptionlabs.ai/api-keys" -credentials = ["env:INCEPTION_API_KEY", "vault:INCEPTION_API_KEY"] -enabled = true - -[providers."inception".models."mercury-2".metadata.fabro] -family = "mercury" -agent_profile = "openai" -small_default = false -probe = false - -[providers."litellm"] -priority = 50 - -[providers."litellm".metadata.fabro] -credentials = ["env:LITELLM_API_KEY", "vault:LITELLM_API_KEY"] -enabled = false - -[providers."minimax"] -priority = 50 -default_model = "minimax-m2.5" - -[providers."minimax".metadata.fabro] -api_key_url = "https://platform.minimaxi.com/user-center/basic-information/interface-key" -credentials = ["env:MINIMAX_API_KEY", "vault:MINIMAX_API_KEY"] -enabled = true - -[providers."minimax".models."minimax-m2.5".metadata.fabro] -family = "minimax-m2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."modal"] -priority = 75 - -[providers."modal".metadata.fabro] -api_key_url = "https://modal.com/docs/guide/endpoints#proxy-tokens" -enabled = false - -[providers."modal".metadata.fabro.extra_headers] -"Modal-Key" = "{{ secrets.MODAL_TOKEN_ID }}" -"Modal-Secret" = "{{ secrets.MODAL_TOKEN_SECRET }}" - -[providers."moonshot"] -priority = 70 -default_model = "kimi-k3" - -[providers."moonshot".metadata.fabro] -api_key_url = "https://platform.kimi.ai/console/api-keys" -credentials = ["env:MOONSHOT_API_KEY", "env:KIMI_API_KEY", "vault:MOONSHOT_API_KEY", "vault:KIMI_API_KEY"] -enabled = true - -[providers."moonshot".models."kimi-k2.5".metadata.fabro] -family = "kimi-k2" -agent_profile = "kimi" -small_default = false -probe = false - -# Moonshot documents K3 as always reasoning with low, high, and max effort; -# lithos leaves the levels unverified, so Fabro records them here. -[providers."moonshot".models."kimi-k3"] -capabilities = { reasoning_effort = { minimal = false, low = true, medium = false, high = true, xhigh = false, max = true } } - -[providers."moonshot".models."kimi-k3".metadata.fabro] -family = "kimi-k3" -agent_profile = "kimi" -small_default = false -probe = false - -[providers."ollama"] -priority = 30 - -[providers."ollama".metadata.fabro] -enabled = false - -[providers."openai"] -priority = 90 -default_model = "gpt-5.6-sol" - -[providers."openai".metadata.fabro] -api_key_url = "https://platform.openai.com/api-keys" -credentials = ["env:OPENAI_API_KEY", "vault:OPENAI_API_KEY"] -enabled = true - -[providers."openai".models."gpt-5.6-sol".metadata.fabro] -family = "gpt-5" -agent_profile = "gpt56" -small_default = false -probe = false - -[providers."openai".models."gpt-5.6-terra".metadata.fabro] -family = "gpt-5" -agent_profile = "gpt56" -small_default = false -probe = false - -[providers."openai".models."gpt-5.6-luna".metadata.fabro] -family = "gpt-5" -agent_profile = "gpt56" -small_default = false -probe = false - -[providers."openai".models."gpt-5.4".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openai".models."gpt-5.5".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openai".models."gpt-5.5-pro".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openai".models."gpt-5.4-pro".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openai".models."gpt-5.4-mini".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = true -probe = true - -[providers."openrouter"] -priority = 25 -default_model = "claude-sonnet-5" - -[providers."openrouter".metadata.fabro] -api_key_url = "https://openrouter.ai/keys" -credentials = ["env:OPENROUTER_API_KEY", "vault:OPENROUTER_API_KEY"] -enabled = false - -[providers."openrouter".models."claude-fable-5".metadata.fabro] -family = "claude-5" -agent_profile = "claude-5" -small_default = false -probe = false - -[providers."openrouter".models."claude-opus-5".metadata.fabro] -training = "2026-05-01" -knowledge_cutoff = "May 2026" -family = "claude-5" -agent_profile = "claude-5" -small_default = false -probe = false - -[providers."openrouter".models."claude-sonnet-5".metadata.fabro] -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" -family = "claude-5" -agent_profile = "claude-5" -small_default = false -probe = false - -[providers."openrouter".models."claude-opus-4.8".metadata.fabro] -training = "2026-01-01" -knowledge_cutoff = "Jan 2026" -family = "claude-4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."claude-opus-4.7".metadata.fabro] -family = "claude-4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."claude-sonnet-4.6".metadata.fabro] -family = "claude-4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."claude-haiku-4.5".metadata.fabro] -family = "claude-4" -agent_profile = "openai" -small_default = true -probe = false - -[providers."openrouter".models."gpt-5.6-sol".metadata.fabro] -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -family = "gpt-5" -agent_profile = "gpt56" -small_default = false -probe = false - -[providers."openrouter".models."gpt-5.6-terra".metadata.fabro] -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -family = "gpt-5" -agent_profile = "gpt56" -small_default = false -probe = false - -[providers."openrouter".models."gpt-5.6-luna".metadata.fabro] -training = "2026-02-16" -knowledge_cutoff = "February 16, 2026" -family = "gpt-5" -agent_profile = "gpt56" -small_default = false -probe = false - -[providers."openrouter".models."gpt-5.4".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."gpt-5.5".metadata.fabro] -family = "gpt-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."gemini-3.1-pro-preview".metadata.fabro] -family = "gemini-3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."gemini-3.5-flash".metadata.fabro] -family = "gemini-3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."mimo-v2.5-pro".metadata.fabro] -family = "mimo-v2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."minimax-m2.7".metadata.fabro] -family = "minimax-m2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."deepseek-v4-pro".metadata.fabro] -reasoning_by_default = true -family = "deepseek-v4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."deepseek-v4-flash".metadata.fabro] -reasoning_by_default = true -family = "deepseek-v4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."kimi-k2.6".metadata.fabro] -family = "kimi-k2" -agent_profile = "kimi" -small_default = false -probe = false - -# Moonshot documents K3 as always reasoning with low, high, and max effort; -# lithos leaves the levels unverified, so Fabro records them here. -[providers."openrouter".models."kimi-k3"] -capabilities = { reasoning_effort = { minimal = false, low = true, medium = false, high = true, xhigh = false, max = true } } - -[providers."openrouter".models."kimi-k3".metadata.fabro] -family = "kimi-k3" -agent_profile = "kimi" -small_default = false -probe = false - -[providers."openrouter".models."laguna-s-2.1".metadata.fabro] -family = "laguna-2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."laguna-xs-2.1".metadata.fabro] -family = "laguna-2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."qwen3-coder".metadata.fabro] -family = "qwen3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."qwen3.6-flash".metadata.fabro] -family = "qwen3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."qwen3.8-max".metadata.fabro] -family = "qwen3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."glm-5.2".metadata.fabro] -family = "glm-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."glm-4.6".metadata.fabro] -family = "glm-4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."nemotron-3-super-120b-a12b".metadata.fabro] -family = "nemotron-3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."openrouter".models."devstral-2512".metadata.fabro] -family = "devstral" -agent_profile = "openai" -small_default = false -probe = false - -[providers."poolside"] -priority = 65 -default_model = "laguna-s-2.1" - -[providers."poolside".metadata.fabro] -api_key_url = "https://platform.poolside.ai" -credentials = ["env:POOLSIDE_API_KEY", "vault:POOLSIDE_API_KEY"] -enabled = true - -[providers."poolside".models."laguna-s-2.1".metadata.fabro] -family = "laguna-2" -agent_profile = "openai" -small_default = false -probe = false - -[providers."poolside".models."laguna-xs-2.1".metadata.fabro] -family = "laguna-2" -agent_profile = "openai" -small_default = true -probe = true - -[providers."venice"] -priority = 35 -default_model = "deepseek-v4-flash" - -[providers."venice".metadata.fabro] -credentials = ["env:VENICE_API_KEY", "vault:VENICE_API_KEY"] -enabled = true - -[providers."venice".models."kimi-k3".metadata.fabro] -reasoning_by_default = true -family = "kimi-k3" -agent_profile = "kimi" -small_default = false -probe = false - -[providers."venice".models."kimi-k3-fast".metadata.fabro] -reasoning_by_default = true -family = "kimi-k3" -agent_profile = "kimi" -small_default = false -probe = false - -[providers."venice".models."grok-4.6".metadata.fabro] -reasoning_by_default = true -family = "grok-4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."venice".models."glm-5.3".metadata.fabro] -reasoning_by_default = true -family = "glm-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."venice".models."deepseek-v4-flash".metadata.fabro] -reasoning_by_default = true -family = "deepseek-v4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."venice".models."deepseek-v4-pro".metadata.fabro] -reasoning_by_default = true -family = "deepseek-v4" -agent_profile = "openai" -small_default = false -probe = false - -[providers."venice".models."qwen3.8-max".metadata.fabro] -reasoning_by_default = true -family = "qwen3" -agent_profile = "openai" -small_default = false -probe = false - -[providers."venice".models."qwen3.8-27b".metadata.fabro] -reasoning_by_default = true -family = "qwen3.8" -agent_profile = "openai" -small_default = false -probe = false - -[providers."zai"] -priority = 60 -default_model = "glm-5.2" - -[providers."zai".metadata.fabro] -api_key_url = "https://open.bigmodel.cn/usercenter/apikeys" -credentials = ["env:ZAI_API_KEY", "vault:ZAI_API_KEY"] -enabled = true - -[providers."zai".models."glm-5.2".metadata.fabro] -family = "glm-5" -agent_profile = "openai" -small_default = false -probe = false - -[providers."zai".models."glm-4.7".metadata.fabro] -family = "glm-4" -agent_profile = "openai" -small_default = false -probe = false - -# ChatGPT OAuth (Codex) access to the OpenAI roster. lithos speaks the Codex -# deployment through the `openai` adapter in codex mode; this provider exists -# so a `fabro provider login --provider openai` device-flow credential routes -# here while an API key keeps routing to `openai`. It is hidden from listings -# and stands in for `openai` when `openai` itself has no credentials. -[providers."openai-codex"] -display_name = "OpenAI (ChatGPT)" -adapter = "openai" -codec = "openai-responses" -base_url = "https://chatgpt.com/backend-api/codex" -priority = 89 -allow_passthrough = true -default_model = "gpt-5.6-sol" -adapter_options = { mode = "codex" } -default_headers = { originator = "fabro" } - -[providers."openai-codex".auth] -type = "bearer" - -[providers."openai-codex".metadata.fabro] -enabled = true -stands_in_for = "openai" -credentials = ["vault:OPENAI_CODEX"] - -[providers."openai-codex".models."gpt-5.6-sol"] -display_name = "GPT-5.6 Sol" -aliases = ["sol", "gpt-sol", "gpt56-sol", "gpt-56-sol", "gpt-5.6", "gpt56", "gpt-56"] -api_model = "gpt-5.6-sol" -limits = { context_tokens = 1050000, max_output_tokens = 128000 } -capabilities = { text = true, images = true, documents = true, tools = true, response_format = { json_object = true, json_schema = true }, reasoning = true, caching = true, cache_routing = true, tool_choice = { required = true, named = true }, speed = { fast = true, economical = true } } -protocol_options = { reasoning_effort_levels = true } -pricing = { input_usd_micros_per_million = 4000000, output_usd_micros_per_million = 20000000, cached_input_usd_micros_per_million = 400000, cache_write_usd_micros_per_million = 5000000, long_context = { above_input_tokens = 272000, input_usd_micros_per_million = 8000000, output_usd_micros_per_million = 30000000, cached_input_usd_micros_per_million = 800000, cache_write_usd_micros_per_million = 10000000 }, speed = { fast = { input_usd_micros_per_million = 8000000, output_usd_micros_per_million = 40000000, cached_input_usd_micros_per_million = 800000, cache_write_usd_micros_per_million = 10000000 }, economical = { input_usd_micros_per_million = 2000000, output_usd_micros_per_million = 10000000, cached_input_usd_micros_per_million = 200000, cache_write_usd_micros_per_million = 2500000 } } } - -[providers."openai-codex".models."gpt-5.6-sol".metadata.fabro] -agent_profile = "gpt56" -family = "gpt-5" -small_default = false -probe = false - -[providers."openai-codex".models."gpt-5.6-terra"] -display_name = "GPT-5.6 Terra" -aliases = ["terra", "gpt-terra", "gpt56-terra", "gpt-56-terra"] -api_model = "gpt-5.6-terra" -limits = { context_tokens = 1050000, max_output_tokens = 128000 } -capabilities = { text = true, images = true, documents = true, tools = true, response_format = { json_object = true, json_schema = true }, reasoning = true, caching = true, cache_routing = true, tool_choice = { required = true, named = true }, speed = { fast = true, economical = true } } -protocol_options = { reasoning_effort_levels = true } -pricing = { input_usd_micros_per_million = 2000000, output_usd_micros_per_million = 12000000, cached_input_usd_micros_per_million = 200000, cache_write_usd_micros_per_million = 2500000, long_context = { above_input_tokens = 272000, input_usd_micros_per_million = 4000000, output_usd_micros_per_million = 18000000, cached_input_usd_micros_per_million = 400000, cache_write_usd_micros_per_million = 5000000 }, speed = { fast = { input_usd_micros_per_million = 4000000, output_usd_micros_per_million = 24000000, cached_input_usd_micros_per_million = 400000, cache_write_usd_micros_per_million = 5000000 }, economical = { input_usd_micros_per_million = 1000000, output_usd_micros_per_million = 6000000, cached_input_usd_micros_per_million = 100000, cache_write_usd_micros_per_million = 1250000 } } } - -[providers."openai-codex".models."gpt-5.6-terra".metadata.fabro] -agent_profile = "gpt56" -family = "gpt-5" -small_default = false -probe = false - -[providers."openai-codex".models."gpt-5.6-luna"] -display_name = "GPT-5.6 Luna" -aliases = ["luna", "gpt-luna", "gpt56-luna", "gpt-56-luna"] -api_model = "gpt-5.6-luna" -limits = { context_tokens = 1050000, max_output_tokens = 128000 } -capabilities = { text = true, images = true, documents = true, tools = true, response_format = { json_object = true, json_schema = true }, reasoning = true, caching = true, cache_routing = true, tool_choice = { required = true, named = true }, speed = { fast = true, economical = true } } -protocol_options = { reasoning_effort_levels = true } -pricing = { input_usd_micros_per_million = 200000, output_usd_micros_per_million = 1200000, cached_input_usd_micros_per_million = 20000, cache_write_usd_micros_per_million = 250000, long_context = { above_input_tokens = 272000, input_usd_micros_per_million = 400000, output_usd_micros_per_million = 1800000, cached_input_usd_micros_per_million = 40000, cache_write_usd_micros_per_million = 500000 }, speed = { fast = { input_usd_micros_per_million = 400000, output_usd_micros_per_million = 2400000, cached_input_usd_micros_per_million = 40000, cache_write_usd_micros_per_million = 500000 }, economical = { input_usd_micros_per_million = 100000, output_usd_micros_per_million = 600000, cached_input_usd_micros_per_million = 10000, cache_write_usd_micros_per_million = 125000 } } } - -[providers."openai-codex".models."gpt-5.6-luna".metadata.fabro] -agent_profile = "gpt56" -family = "gpt-5" -small_default = false -probe = false - -[providers."openai-codex".models."gpt-5.4"] -display_name = "GPT-5.4" -aliases = ["gpt54", "gpt-54", "gpt-5.2", "gpt5", "gpt-5.3-codex", "codex"] -api_model = "gpt-5.4" -limits = { context_tokens = 1050000, max_output_tokens = 128000 } -capabilities = { text = true, images = true, documents = true, tools = true, response_format = { json_object = true, json_schema = true }, reasoning = true, caching = true, cache_routing = true, sampling = true, tool_choice = { required = true, named = true }, speed = { fast = true, economical = true } } -protocol_options = { reasoning_effort_levels = true } -pricing = { input_usd_micros_per_million = 2500000, output_usd_micros_per_million = 15000000, cached_input_usd_micros_per_million = 250000, long_context = { above_input_tokens = 272000, input_usd_micros_per_million = 5000000, output_usd_micros_per_million = 22500000, cached_input_usd_micros_per_million = 500000 }, speed = { fast = { input_usd_micros_per_million = 5000000, output_usd_micros_per_million = 30000000, cached_input_usd_micros_per_million = 500000 }, economical = { input_usd_micros_per_million = 1250000, output_usd_micros_per_million = 7500000, cached_input_usd_micros_per_million = 125000 } } } - -[providers."openai-codex".models."gpt-5.4".metadata.fabro] -agent_profile = "openai" -family = "gpt-5" -small_default = false -probe = false - -[providers."openai-codex".models."gpt-5.5"] -display_name = "GPT-5.5" -aliases = ["gpt55", "gpt-55"] -api_model = "gpt-5.5" -limits = { context_tokens = 1050000, max_output_tokens = 128000 } -capabilities = { text = true, images = true, documents = true, tools = true, response_format = { json_object = true, json_schema = true }, reasoning = true, caching = true, cache_routing = true, tool_choice = { required = true, named = true }, speed = { fast = true, economical = true } } -protocol_options = { reasoning_effort_levels = true } -pricing = { input_usd_micros_per_million = 5000000, output_usd_micros_per_million = 30000000, cached_input_usd_micros_per_million = 500000, long_context = { above_input_tokens = 272000, input_usd_micros_per_million = 10000000, output_usd_micros_per_million = 45000000, cached_input_usd_micros_per_million = 1000000 }, speed = { fast = { input_usd_micros_per_million = 12500000, output_usd_micros_per_million = 75000000, cached_input_usd_micros_per_million = 1250000 }, economical = { input_usd_micros_per_million = 2500000, output_usd_micros_per_million = 15000000, cached_input_usd_micros_per_million = 250000 } } } - -[providers."openai-codex".models."gpt-5.5".metadata.fabro] -agent_profile = "openai" -family = "gpt-5" -small_default = false -probe = false - -[providers."openai-codex".models."gpt-5.4-mini"] -display_name = "GPT-5.4 Mini" -aliases = ["gpt54-mini", "gpt-54-mini", "gpt-5.3-codex-spark", "codex-spark"] -api_model = "gpt-5.4-mini" -limits = { context_tokens = 400000, max_output_tokens = 128000 } -capabilities = { text = true, images = true, documents = true, tools = true, response_format = { json_object = true, json_schema = true }, reasoning = true, caching = true, cache_routing = true, sampling = true, tool_choice = { required = true, named = true }, speed = { fast = true, economical = true } } -protocol_options = { reasoning_effort_levels = true } -pricing = { input_usd_micros_per_million = 750000, output_usd_micros_per_million = 4500000, cached_input_usd_micros_per_million = 75000, speed = { fast = { input_usd_micros_per_million = 1500000, output_usd_micros_per_million = 9000000, cached_input_usd_micros_per_million = 150000 }, economical = { input_usd_micros_per_million = 375000, output_usd_micros_per_million = 2250000, cached_input_usd_micros_per_million = 37500 } } } - -[providers."openai-codex".models."gpt-5.4-mini".metadata.fabro] -agent_profile = "openai" -family = "gpt-5" -small_default = true -probe = true diff --git a/lib/components/fabro-llm/src/api.rs b/lib/components/fabro-llm/src/api.rs index 2b45982eb..b4aa4024e 100644 --- a/lib/components/fabro-llm/src/api.rs +++ b/lib/components/fabro-llm/src/api.rs @@ -1,7 +1,7 @@ //! API projections of the catalog for `GET /models` and `GET /providers`. //! -//! Every row is a lithos catalog entry plus its Fabro policy, stamped with -//! whether the caller holds credential material for the provider. +//! Every row is a lithos catalog entry stamped with whether the caller holds +//! credential material for the provider. use std::collections::HashSet; @@ -9,9 +9,9 @@ use fabro_types::controls::REASONING_EFFORTS; use fabro_types::{ Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId, }; -use lithos_llm::catalog::Catalog; +use lithos_llm::catalog::{Catalog, CatalogProvider}; -use crate::catalog::{self, ModelEntry, ProviderEntry}; +use crate::catalog::{self, ModelEntry}; const USD_MICROS_PER_USD: f64 = 1_000_000.0; @@ -29,7 +29,7 @@ pub fn models(catalog: &Catalog, configured: &HashSet) -> Vec pub fn providers(catalog: &Catalog, configured: &HashSet) -> Vec { catalog::listed_providers(catalog) .iter() - .map(|entry| provider_view(entry, configured.contains(entry.provider.id()))) + .map(|provider| provider_view(provider, configured.contains(provider.id()))) .collect() } @@ -41,11 +41,9 @@ fn model_view(entry: &ModelEntry<'_>, configured: bool) -> Model { Model { id: model.id().clone(), provider: entry.provider.id().clone(), - family: entry - .policy - .family - .clone() - .unwrap_or_else(|| model.id().to_string()), + family: model + .family() + .map_or_else(|| model.id().to_string(), str::to_string), display_name: model.display_name().to_string(), limits: ModelLimits { context_window: limits.map_or(0, |limits| saturating_i64(limits.context_tokens)), @@ -54,8 +52,8 @@ fn model_view(entry: &ModelEntry<'_>, configured: bool) -> Model { .filter(|tokens| *tokens > 0) .map(saturating_i64), }, - training: entry.policy.training.clone(), - knowledge_cutoff: entry.policy.knowledge_cutoff.clone(), + training: model.training_cutoff().map(str::to_string), + knowledge_cutoff: model.knowledge_cutoff().map(str::to_string), features: ModelFeatures { tools: capabilities.tools().is_supported(), vision: capabilities.images().is_supported(), @@ -81,22 +79,21 @@ fn model_view(entry: &ModelEntry<'_>, configured: bool) -> Model { .and_then(|pricing| pricing.cached_input_usd_micros_per_million) .map(usd_per_million), }, - estimated_output_tps: entry.policy.estimated_output_tps, + estimated_output_tps: model.estimated_output_tps(), aliases: model.aliases().to_vec(), default: entry.provider.default_model() == Some(model.id().as_str()), - small_default: entry.policy.small_default, + small_default: model.is_small_default(), configured, } } -fn provider_view(entry: &ProviderEntry<'_>, configured: bool) -> Provider { - let provider = entry.provider; +fn provider_view(provider: &CatalogProvider, configured: bool) -> Provider { Provider { id: provider.id().clone(), display_name: provider.display_name().to_string(), adapter: provider.adapter().as_str().to_string(), base_url: provider.base_url().to_string(), - api_key_url: entry.policy.api_key_url.clone(), + api_key_url: provider.api_key_url().map(str::to_string), priority: provider.priority(), aliases: provider.aliases().to_vec(), model_count: u32::try_from(catalog::provider_models(provider).len()).unwrap_or(u32::MAX), diff --git a/lib/components/fabro-llm/src/catalog.rs b/lib/components/fabro-llm/src/catalog.rs index 939655cbd..a12b27c8a 100644 --- a/lib/components/fabro-llm/src/catalog.rs +++ b/lib/components/fabro-llm/src/catalog.rs @@ -1,20 +1,22 @@ -//! Catalog construction and Fabro-policy queries. +//! Catalog construction and the queries Fabro's dispatch boundaries share. //! -//! Layer order is fixed: lithos built-ins, then Fabro's policy layer, then the -//! operator's `[llm]` overlay. Every query here reads Fabro policy from the -//! `metadata.fabro` namespace and never bypasses `enabled`. +//! Layer order is fixed: lithos built-ins, then the operator's `[llm]` +//! overlay. Provider and model facts, `enabled`, `stands_in_for`, +//! `small_default`, and `probe` are lithos core fields. The agent harness a +//! model expects lives in the shared `metadata.agent` namespace, which Pebble +//! reads too. Every query here skips disabled providers. use std::collections::{BTreeMap, HashSet}; use fabro_config::LlmLayer; use fabro_static::EnvVars; -use fabro_types::catalog_policy::{self, ModelPolicy, ProviderPolicy}; use fabro_types::{AgentProfileKind, Cost, ModelId, ModelRef, ProviderId, TokenCounts}; -use lithos_llm::catalog::{Catalog, CatalogError, CatalogModel, CatalogProvider}; +use lithos_llm::catalog::{Catalog, CatalogError, CatalogModel, CatalogProvider, Metadata}; use lithos_llm::resolver::ResolvedRoute; +use serde::Deserialize; -/// Fabro's policy layer, applied above the lithos built-ins. -pub const FABRO_POLICY_TOML: &str = include_str!("../catalog/fabro-policy.toml"); +/// The metadata namespace agent harnesses read. +const AGENT_METADATA_NAMESPACE: &str = "agent"; /// Builds the effective catalog. /// @@ -25,9 +27,7 @@ pub fn build_catalog( overlay: &LlmLayer, env_lookup: &dyn Fn(&str) -> Option, ) -> Result { - let mut builder = Catalog::builder() - .with_builtin() - .toml_layer("fabro-policy.toml", FABRO_POLICY_TOML)?; + let mut builder = Catalog::builder().with_builtin(); if !overlay.is_empty() { let mut document = overlay.to_overlay_toml(); document.insert_str(0, "schema_version = 1\n"); @@ -43,50 +43,78 @@ pub fn build_catalog( builder.build() } -/// A provider with its Fabro policy attached. -#[derive(Debug, Clone)] -pub struct ProviderEntry<'a> { - pub provider: &'a CatalogProvider, - pub policy: ProviderPolicy, -} - -/// A model with its Fabro policy attached. -#[derive(Debug, Clone)] -pub struct ModelEntry<'a> { - pub provider: &'a CatalogProvider, - pub model: &'a CatalogModel, - pub policy: ModelPolicy, -} - -impl ModelEntry<'_> { - /// Whether requests to this model reason when no effort is requested. - /// - /// Fabro policy can state it outright. Otherwise a model that supports - /// reasoning and takes named effort levels reasons by default, while one - /// that needs an explicit thinking budget does not. - #[must_use] - pub fn reasons_by_default(&self) -> bool { - self.policy.reasoning_by_default.unwrap_or_else(|| { - self.model.capabilities().reasoning().is_supported() - && self.model.protocol_options().reasoning_effort_levels - }) - } - - #[must_use] - pub fn agent_profile(&self) -> AgentProfileKind { - catalog_policy::effective_agent_profile(self.provider, self.model) - } -} - -/// The catalog with no operator overlay: lithos built-ins plus Fabro policy. +/// The catalog with no operator overlay: the lithos built-ins. /// /// Used where no settings file is in play, such as the standalone hook /// runner. Servers and the CLI build from the operator's `[llm]` overlay /// with [`build_catalog`] instead. #[must_use] pub fn default_catalog() -> Catalog { - build_catalog(&LlmLayer::default(), &|_| None) - .expect("the built-in catalog and Fabro policy layer always build") + build_catalog(&LlmLayer::default(), &|_| None).expect("the built-in catalog always builds") +} + +/// A model on the provider that offers it. +#[derive(Debug, Clone)] +pub struct ModelEntry<'a> { + pub provider: &'a CatalogProvider, + pub model: &'a CatalogModel, +} + +impl ModelEntry<'_> { + /// Whether requests to this model reason when no effort is requested. + /// + /// The catalog can state it outright under `metadata.agent`. Otherwise a + /// model that supports reasoning and takes named effort levels reasons by + /// default, while one that needs an explicit thinking budget does not. + #[must_use] + pub fn reasons_by_default(&self) -> bool { + agent_metadata(self.model.metadata()) + .reasoning_by_default + .or(agent_metadata(self.provider.metadata()).reasoning_by_default) + .unwrap_or_else(|| { + self.model.capabilities().reasoning().is_supported() + && self.model.protocol_options().reasoning_effort_levels + }) + } + + /// The agent harness this model runs under: the model's own answer, then + /// the provider's, then the profile implied by the provider's adapter. + #[must_use] + pub fn agent_profile(&self) -> AgentProfileKind { + agent_metadata(self.model.metadata()) + .profile + .unwrap_or_else(|| provider_agent_profile(self.provider)) + } +} + +/// The `metadata.agent` namespace on a catalog entry. Malformed metadata +/// falls back to the defaults; the lithos built-ins are validated in lithos. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct AgentMetadata { + profile: Option, + reasoning_by_default: Option, +} + +fn agent_metadata(metadata: &Metadata) -> AgentMetadata { + metadata + .namespace::(AGENT_METADATA_NAMESPACE) + .ok() + .flatten() + .unwrap_or_default() +} + +/// The agent profile a provider's models run under unless a model row says +/// otherwise: the provider's `metadata.agent.profile`, else the profile +/// implied by its wire protocol. +fn provider_agent_profile(provider: &CatalogProvider) -> AgentProfileKind { + agent_metadata(provider.metadata()) + .profile + .unwrap_or_else(|| match provider.adapter().as_str() { + "anthropic" | "bedrock" => AgentProfileKind::Anthropic, + "gemini" => AgentProfileKind::Gemini, + _ => AgentProfileKind::OpenAi, + }) } /// Estimates the catalog cost of `usage` on `model`, when the catalog prices @@ -101,21 +129,16 @@ pub fn estimate_cost(catalog: &Catalog, model: &ModelRef, usage: TokenCounts) -> /// Enabled providers, highest priority first, ties broken by id. #[must_use] -pub fn enabled_providers(catalog: &Catalog) -> Vec> { +pub fn enabled_providers(catalog: &Catalog) -> Vec<&CatalogProvider> { let mut providers: Vec<_> = catalog .providers() - .map(|provider| ProviderEntry { - provider, - policy: catalog_policy::provider_policy(provider), - }) - .filter(|entry| entry.policy.is_enabled()) + .filter(|provider| provider.is_enabled()) .collect(); providers.sort_by(|left, right| { right - .provider .priority() - .cmp(&left.provider.priority()) - .then_with(|| left.provider.id().cmp(right.provider.id())) + .cmp(&left.priority()) + .then_with(|| left.id().cmp(right.id())) }); providers } @@ -123,10 +146,10 @@ pub fn enabled_providers(catalog: &Catalog) -> Vec> { /// Enabled providers that Fabro lists to operators. Stand-in providers such /// as `openai-codex` route requests but are not offerings of their own. #[must_use] -pub fn listed_providers(catalog: &Catalog) -> Vec> { +pub fn listed_providers(catalog: &Catalog) -> Vec<&CatalogProvider> { enabled_providers(catalog) .into_iter() - .filter(|entry| entry.policy.stands_in_for.is_none()) + .filter(|provider| provider.stands_in_for().is_none()) .collect() } @@ -135,82 +158,70 @@ pub fn listed_providers(catalog: &Catalog) -> Vec> { pub fn enabled_provider_ids(catalog: &Catalog) -> HashSet { enabled_providers(catalog) .into_iter() - .map(|entry| entry.provider.id().clone()) + .map(|provider| provider.id().clone()) .collect() } /// Looks up an enabled provider by id or alias. #[must_use] -pub fn provider<'a>(catalog: &'a Catalog, selector: &str) -> Option> { - let provider = catalog.provider(selector).ok()?; - let policy = catalog_policy::provider_policy(provider); - policy - .is_enabled() - .then_some(ProviderEntry { provider, policy }) +pub fn provider<'a>(catalog: &'a Catalog, selector: &str) -> Option<&'a CatalogProvider> { + catalog + .provider(selector) + .ok() + .filter(|provider| provider.is_enabled()) } /// Canonicalizes a provider id or alias to its catalog id, when enabled. #[must_use] pub fn canonical_provider_id(catalog: &Catalog, selector: &str) -> Option { - provider(catalog, selector).map(|entry| entry.provider.id().clone()) + provider(catalog, selector).map(|provider| provider.id().clone()) } -/// Enabled models of an enabled provider, in catalog order. +/// The models of a provider, in catalog order. #[must_use] pub fn provider_models(provider: &CatalogProvider) -> Vec> { provider .models() - .map(|model| ModelEntry { - provider, - model, - policy: catalog_policy::model_policy(model), - }) - .filter(|entry| entry.policy.is_enabled()) + .map(|model| ModelEntry { provider, model }) .collect() } -/// Every enabled model across listed providers, provider priority order. +/// Every model across listed providers, provider priority order. #[must_use] pub fn models(catalog: &Catalog) -> Vec> { listed_providers(catalog) .into_iter() - .flat_map(|entry| provider_models(entry.provider)) + .flat_map(provider_models) .collect() } -/// Finds an enabled model on an enabled provider by id, alias, or wire id. +/// Finds a model on an enabled provider by id, alias, or wire id. #[must_use] pub fn model_on_provider<'a>( catalog: &'a Catalog, provider_selector: &str, model_selector: &str, ) -> Option> { - let entry = provider(catalog, provider_selector)?; + let provider = provider(catalog, provider_selector)?; // lithos matches ids and aliases. The provider's wire id (an aggregator's // `vendor/model`) is accepted too, so a selector copied from the // provider's own listing lands on the catalog row instead of passing // through unknown. - let model = entry.provider.model(model_selector).or_else(|| { - entry - .provider + let model = provider.model(model_selector).or_else(|| { + provider .models() .find(|model| model.api_model() == model_selector) })?; - let policy = catalog_policy::model_policy(model); - policy.is_enabled().then_some(ModelEntry { - provider: entry.provider, - model, - policy, - }) + Some(ModelEntry { provider, model }) } -/// Enabled models matching `selector` by id or alias, ordered like lithos -/// selection: exact ids before aliases, then provider priority. +/// Models matching `selector` by id or alias, ordered like lithos selection: +/// exact ids before aliases, then provider priority. #[must_use] pub fn models_matching<'a>(catalog: &'a Catalog, selector: &str) -> Vec> { let mut matches: Vec<_> = enabled_providers(catalog) .into_iter() - .flat_map(|entry| provider_models(entry.provider)) + .flat_map(provider_models) .filter(|entry| { entry.model.id().as_str() == selector || entry.model.aliases().iter().any(|alias| alias == selector) @@ -220,7 +231,7 @@ pub fn models_matching<'a>(catalog: &'a Catalog, selector: &str) -> Vec bool { !models_matching(catalog, selector).is_empty() @@ -232,22 +243,22 @@ pub fn is_provider_selector(catalog: &Catalog, selector: &str) -> bool { provider(catalog, selector).is_some() } -/// The enabled default model of an enabled provider. +/// The default model of an enabled provider. #[must_use] pub fn default_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { - let entry = provider(catalog, provider_selector)?; - let default = entry.provider.default_model()?; - model_on_provider(catalog, entry.provider.id().as_str(), default) + let provider = provider(catalog, provider_selector)?; + let default = provider.default_model()?; + model_on_provider(catalog, provider.id().as_str(), default) } /// The model Fabro probes a provider with: the `probe` model, else the /// provider default. #[must_use] pub fn probe_model<'a>(catalog: &'a Catalog, provider_selector: &str) -> Option> { - let entry = provider(catalog, provider_selector)?; - provider_models(entry.provider) + let provider = provider(catalog, provider_selector)?; + provider_models(provider) .into_iter() - .find(|model| model.policy.probe) + .find(|entry| entry.model.is_probe()) .or_else(|| default_model(catalog, provider_selector)) } @@ -262,9 +273,9 @@ pub fn default_for_ready<'a>( let providers = enabled_providers(catalog); providers .iter() - .filter(|entry| ready.contains(entry.provider.id())) + .filter(|provider| ready.contains(provider.id())) .chain(providers.iter()) - .find_map(|entry| default_model(catalog, entry.provider.id().as_str())) + .find_map(|provider| default_model(catalog, provider.id().as_str())) } /// The small utility model across `ready` providers: the first @@ -274,12 +285,11 @@ pub fn small_default_for_ready<'a>( catalog: &'a Catalog, ready: &HashSet, ) -> Option> { - let providers = enabled_providers(catalog); - providers - .iter() - .filter(|entry| ready.contains(entry.provider.id())) - .flat_map(|entry| provider_models(entry.provider)) - .find(|model| model.policy.small_default) + enabled_providers(catalog) + .into_iter() + .filter(|provider| ready.contains(provider.id())) + .flat_map(provider_models) + .find(|entry| entry.model.is_small_default()) .or_else(|| default_for_ready(catalog, ready)) } @@ -306,14 +316,11 @@ pub fn agent_profile( provider_selector: &str, model_selector: Option<&str>, ) -> Option { - let entry = provider(catalog, provider_selector)?; - let model = model_selector.and_then(|selector| entry.provider.model(selector)); + let provider = provider(catalog, provider_selector)?; + let model = model_selector.and_then(|selector| provider.model(selector)); Some(match model { - Some(model) => catalog_policy::effective_agent_profile(entry.provider, model), - None => entry - .policy - .agent_profile - .unwrap_or_else(|| catalog_policy::default_agent_profile(entry.provider)), + Some(model) => ModelEntry { provider, model }.agent_profile(), + None => provider_agent_profile(provider), }) } @@ -331,7 +338,7 @@ pub fn closest_model<'a>( .pricing() .and_then(|pricing| pricing.input_usd_micros_per_million) .unwrap_or(0); - provider_models(target.provider) + provider_models(target) .into_iter() .filter(|entry| { let caps = entry.model.capabilities(); @@ -354,12 +361,12 @@ pub fn closest_model<'a>( pub fn model_ids_by_provider(catalog: &Catalog) -> BTreeMap> { listed_providers(catalog) .into_iter() - .map(|entry| { + .map(|provider| { ( - entry.provider.id().clone(), - provider_models(entry.provider) + provider.id().clone(), + provider_models(provider) .into_iter() - .map(|model| model.model.id().clone()) + .map(|entry| entry.model.id().clone()) .collect(), ) }) @@ -372,11 +379,11 @@ mod tests { use crate::test_support::test_catalog; #[test] - fn policy_layer_builds_over_the_builtins() { + fn builtins_ship_fabro_defaults() { let catalog = test_catalog(); let ids: Vec<_> = enabled_providers(&catalog) .iter() - .map(|entry| entry.provider.id().to_string()) + .map(|provider| provider.id().to_string()) .collect(); assert_eq!(ids[0], "anthropic"); assert!(ids.contains(&"openai".to_string())); @@ -387,7 +394,7 @@ mod tests { assert!( !listed_providers(&catalog) .iter() - .any(|entry| entry.provider.id().as_str() == "openai-codex"), + .any(|provider| provider.id().as_str() == "openai-codex"), "stand-in providers are not listed" ); } @@ -399,7 +406,6 @@ mod tests { r" [providers.openai] priority = 500 -[providers.openai.metadata.fabro] enabled = false ", ) @@ -410,7 +416,7 @@ enabled = false assert_eq!( catalog.provider("openai").unwrap().priority(), 500, - "overlay values win over the policy layer" + "overlay values win over the built-ins" ); } @@ -427,7 +433,7 @@ enabled = false } #[test] - fn probe_and_small_default_follow_policy() { + fn probe_and_small_default_follow_the_catalog() { let catalog = test_catalog(); assert_eq!( probe_model(&catalog, "openai").unwrap().model.id().as_str(), @@ -488,11 +494,41 @@ enabled = false ); assert_eq!( agent_profile(&catalog, "moonshot", None), + Some(AgentProfileKind::Kimi), + "a passthrough model on Moonshot takes the provider's Kimi profile" + ); + assert_eq!( + agent_profile(&catalog, "deepseek", None), Some(AgentProfileKind::OpenAi) ); + assert_eq!( + agent_profile(&catalog, "openrouter", None), + None, + "disabled providers have no profile to offer" + ); assert_eq!( agent_profile(&catalog, "moonshot", Some("kimi-k3")), Some(AgentProfileKind::Kimi) ); + assert_eq!( + agent_profile(&catalog, "openai", Some("gpt-6-astra")), + Some(AgentProfileKind::Gpt6) + ); + assert_eq!( + agent_profile(&catalog, "anthropic", Some("claude-sonnet-4.5")), + Some(AgentProfileKind::Anthropic) + ); + } + + #[test] + fn reasoning_by_default_reads_agent_metadata_then_capabilities() { + let catalog = test_catalog(); + let kimi = model_on_provider(&catalog, "moonshot", "kimi-k2.5").unwrap(); + assert!(kimi.reasons_by_default(), "the catalog row says so"); + let sonnet = model_on_provider(&catalog, "anthropic", "claude-sonnet-4.5").unwrap(); + assert!( + !sonnet.reasons_by_default(), + "a thinking-budget model reasons only when asked" + ); } } diff --git a/lib/components/fabro-llm/src/lib.rs b/lib/components/fabro-llm/src/lib.rs index 52d04d12e..31cd9d74b 100644 --- a/lib/components/fabro-llm/src/lib.rs +++ b/lib/components/fabro-llm/src/lib.rs @@ -3,8 +3,9 @@ //! lithos owns the LLM vocabulary, the provider catalog, the wire codecs, and //! the client. This crate adds what is specific to Fabro: //! -//! - building the catalog from lithos built-ins, Fabro's policy layer, and the -//! operator `[llm]` overlay ([`catalog`]); +//! - building the catalog from the lithos built-ins and the operator `[llm]` +//! overlay, and the catalog queries Fabro's dispatch boundaries share +//! ([`catalog`]); //! - Fabro's passthrough policy for selections made before a request exists //! ([`selection`]); at request time the lithos resolver enforces `enabled` //! and `stands_in_for` itself; @@ -33,7 +34,7 @@ pub mod structured; #[cfg(any(test, feature = "test-support"))] pub mod test_support; -pub use catalog::{FABRO_POLICY_TOML, build_catalog, default_catalog}; +pub use catalog::{build_catalog, default_catalog}; pub use client::{ ClientOptions, FabroClient, LlmSetupError, RetryListener, RetryNotice, build_client, build_offline_client, diff --git a/lib/components/fabro-llm/src/probe.rs b/lib/components/fabro-llm/src/probe.rs index 0d52e5aa1..1e2cbcb25 100644 --- a/lib/components/fabro-llm/src/probe.rs +++ b/lib/components/fabro-llm/src/probe.rs @@ -104,10 +104,10 @@ pub async fn probe_provider_with_api_key( api_key: String, timeout: Duration, ) -> Result { - let entry = catalog::provider(&catalog, provider.as_str()) + let catalog_provider = catalog::provider(&catalog, provider.as_str()) .ok_or_else(|| ApiKeyProbeError::UnknownProvider(provider.to_string()))?; - let provider_id = entry.provider.id().clone(); - if !fabro_auth::accepts_api_key(entry.provider) { + let provider_id = catalog_provider.id().clone(); + if !fabro_auth::accepts_api_key(catalog_provider) { return Err(ApiKeyProbeError::NoApiKeyPath(provider_id)); } let model = catalog::probe_model(&catalog, provider_id.as_str()) diff --git a/lib/components/fabro-llm/src/selection.rs b/lib/components/fabro-llm/src/selection.rs index 791872cbf..80c6d6fbd 100644 --- a/lib/components/fabro-llm/src/selection.rs +++ b/lib/components/fabro-llm/src/selection.rs @@ -13,8 +13,8 @@ //! - No selector picks the default offering (of the pinned provider, when one //! is given). //! -//! Only enabled providers and models take part. Disabled ones are invisible -//! here, exactly as they are to the client's resolver. +//! Only enabled providers take part. Disabled ones are invisible here, +//! exactly as they are to the lithos resolver at request time. use std::collections::HashSet; use std::fmt; @@ -115,7 +115,7 @@ pub fn ready_provider( } } -/// Finds `selector` as an enabled model on an enabled provider. +/// Finds `selector` as a model on an enabled provider. pub fn resolve_on_provider<'a>( catalog: &'a Catalog, provider: &ProviderId, @@ -182,9 +182,9 @@ pub fn select_default<'a>( let eligible = canonical_eligible(catalog, eligible); let providers_with_defaults: Vec<_> = catalog::enabled_providers(catalog) .into_iter() - .filter_map(|entry| { - catalog::default_model(catalog, entry.provider.id().as_str()) - .map(|model| (entry.provider.id().clone(), model)) + .filter_map(|provider| { + catalog::default_model(catalog, provider.id().as_str()) + .map(|model| (provider.id().clone(), model)) }) .collect(); providers_with_defaults @@ -350,8 +350,7 @@ mod tests { #[test] fn slash_selector_with_a_non_provider_prefix_matches_api_ids_on_a_pinned_provider() { - let catalog = - test_catalog_with_overlay("[providers.openrouter.metadata.fabro]\nenabled = true\n"); + let catalog = test_catalog_with_overlay("[providers.openrouter]\nenabled = true\n"); let selected = resolve_selection( &catalog, Some("openai/gpt-5.6-sol"), @@ -409,8 +408,7 @@ mod tests { select(&catalog, "gpt-5.4", None, &eligible(&["openrouter"])), Err(ModelSelectionError::NoEligibleOffering { .. }) )); - let enabled = - test_catalog_with_overlay("[providers.openrouter.metadata.fabro]\nenabled = true\n"); + let enabled = test_catalog_with_overlay("[providers.openrouter]\nenabled = true\n"); let entry = select(&enabled, "gpt-5.4", None, &eligible(&["openrouter"])).unwrap(); assert_eq!(entry.provider.id(), &ProviderId::new("openrouter")); } diff --git a/lib/components/fabro-llm/src/test_support.rs b/lib/components/fabro-llm/src/test_support.rs index b10b3ebaa..ac008e96f 100644 --- a/lib/components/fabro-llm/src/test_support.rs +++ b/lib/components/fabro-llm/src/test_support.rs @@ -20,7 +20,7 @@ use lithos_llm::types::{ use crate::client::{ClientOptions, build_client, build_offline_client}; -/// The lithos built-in catalog with Fabro's policy layer applied. +/// The lithos built-in catalog, as Fabro ships it. #[must_use] pub fn test_catalog() -> Catalog { crate::build_catalog(&LlmLayer::default(), &|_| None).expect("test catalog should build") diff --git a/lib/components/fabro-validate/src/lib.rs b/lib/components/fabro-validate/src/lib.rs index b86bc5954..1605dcc3a 100644 --- a/lib/components/fabro-validate/src/lib.rs +++ b/lib/components/fabro-validate/src/lib.rs @@ -221,9 +221,8 @@ base_url = "https://api.venice.ai/api/v1" auth = { type = "bearer" } default_model = "venice-large" -[providers.acme-venice.metadata.fabro] -agent_profile = "openai" -credentials = ["env:VENICE_API_KEY"] +[providers.acme-venice.metadata.agent] +profile = "openai" [providers.acme-venice.models.venice-large] display_name = "Venice Large" diff --git a/lib/components/fabro-validate/src/rules/model_support.rs b/lib/components/fabro-validate/src/rules/model_support.rs index 3c4f3544a..21735ef5a 100644 --- a/lib/components/fabro-validate/src/rules/model_support.rs +++ b/lib/components/fabro-validate/src/rules/model_support.rs @@ -39,7 +39,7 @@ pub(super) fn check_provider_known( } let valid: Vec = catalog::listed_providers(catalog) .iter() - .map(|entry| entry.provider.id().to_string()) + .map(|provider| provider.id().to_string()) .collect(); let valid_str = valid.join(", "); Some(Diagnostic { diff --git a/lib/components/fabro-workflow/src/handler/llm/api.rs b/lib/components/fabro-workflow/src/handler/llm/api.rs index 9ca39a6b9..412e76ede 100644 --- a/lib/components/fabro-workflow/src/handler/llm/api.rs +++ b/lib/components/fabro-workflow/src/handler/llm/api.rs @@ -2065,7 +2065,9 @@ mod tests { } /// An OpenAI-compatible mock provider served by `server`, with one model. - fn mock_provider_overlay(provider: &str, model: &str, base_url: &str, env_var: &str) -> String { + /// Its API key is the name lithos derives from the provider id, such as + /// `MOCK_API_KEY`. + fn mock_provider_overlay(provider: &str, model: &str, base_url: &str) -> String { format!( r#" [providers.{provider}] @@ -2076,9 +2078,8 @@ base_url = {base_url} auth = {{ type = "bearer" }} default_model = "{model}" -[providers.{provider}.metadata.fabro] -agent_profile = "openai" -credentials = ["env:{env_var}"] +[providers.{provider}.metadata.agent] +profile = "openai" [providers.{provider}.models.{model}] display_name = "{model}" @@ -2095,7 +2096,6 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = "mock", "mock-model", &server.base_url(), - "MOCK_API_KEY", ))) } @@ -2103,7 +2103,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = /// would so their models become fallback targets. fn enabled_fallback_catalog() -> Arc { Arc::new(test_catalog_with_overlay( - "[providers.modal.metadata.fabro]\nenabled = true\n\n[providers.openrouter.metadata.fabro]\nenabled = true\n", + "[providers.modal]\nenabled = true\n\n[providers.openrouter]\nenabled = true\n", )) } @@ -2132,13 +2132,11 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = "primary", "test-model", &format!("{}/primary", server.base_url()), - "PRIMARY_API_KEY", ), mock_provider_overlay( "fallback", "test-model", &format!("{}/fallback", server.base_url()), - "FALLBACK_API_KEY", ), ); let catalog = Arc::new(test_catalog_with_overlay(&overlay)); @@ -4073,7 +4071,7 @@ capabilities = {{ text = true, tools = true, response_format = {{ json_object = })) } - const OPENROUTER_ENABLED: &str = "[providers.openrouter.metadata.fabro]\nenabled = true\n"; + const OPENROUTER_ENABLED: &str = "[providers.openrouter]\nenabled = true\n"; /// An operator-defined OpenAI-compatible provider whose models take the /// provider's `openai` agent profile. @@ -4086,19 +4084,17 @@ base_url = "https://api.acme.test/v1" auth = { type = "bearer" } default_model = "acme-llama" -[providers.acme.metadata.fabro] -agent_profile = "openai" -credentials = ["env:ACME_API_KEY"] +[providers.acme.metadata.agent] +profile = "openai" [providers.acme.models.acme-llama] display_name = "Acme Llama" api_model = "acme-llama" limits = { context_tokens = 131072, max_output_tokens = 8192 } capabilities = { text = true, tools = true } - -[providers.acme.models.acme-llama.metadata.fabro] family = "llama" -training = "2026-01" +training_cutoff = "2026-01" + "#; /// The same provider serving a Claude model that overrides the profile. @@ -4111,9 +4107,8 @@ base_url = "https://api.acme.test/v1" auth = { type = "bearer" } default_model = "acme-claude" -[providers.acme.metadata.fabro] -agent_profile = "openai" -credentials = ["env:ACME_API_KEY"] +[providers.acme.metadata.agent] +profile = "openai" [providers.acme.models.acme-claude] display_name = "Acme Claude" @@ -4121,11 +4116,11 @@ aliases = ["ac"] api_model = "acme-claude" limits = { context_tokens = 131072, max_output_tokens = 8192 } capabilities = { text = true, tools = true } - -[providers.acme.models.acme-claude.metadata.fabro] family = "claude" -training = "2026-01" -agent_profile = "anthropic" +training_cutoff = "2026-01" + +[providers.acme.models.acme-claude.metadata.agent] +profile = "anthropic" "#; #[tokio::test] diff --git a/lib/components/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs index 97c110b2e..5f0778fca 100644 --- a/lib/components/fabro-workflow/src/handler/prompt.rs +++ b/lib/components/fabro-workflow/src/handler/prompt.rs @@ -699,9 +699,8 @@ mod tests { auth = { type = "bearer" } default_model = "acme-claude" - [providers.acme.metadata.fabro] - agent_profile = "openai" - credentials = ["env:ACME_API_KEY"] + [providers.acme.metadata.agent] + profile = "openai" [providers.acme.models.acme-claude] display_name = "Acme Claude" @@ -709,10 +708,10 @@ mod tests { api_model = "acme-claude" limits = { context_tokens = 1000, max_output_tokens = 500 } capabilities = { text = true, tools = true } - - [providers.acme.models.acme-claude.metadata.fabro] family = "claude" - agent_profile = "anthropic" + + [providers.acme.models.acme-claude.metadata.agent] + profile = "anthropic" "#, )); let mut services = make_services(); @@ -774,9 +773,8 @@ mod tests { auth = { type = "bearer" } default_model = "acme-claude" - [providers.acme.metadata.fabro] - agent_profile = "openai" - credentials = ["env:ACME_API_KEY"] + [providers.acme.metadata.agent] + profile = "openai" [providers.acme.models.acme-claude] display_name = "Acme Claude" @@ -784,10 +782,10 @@ mod tests { api_model = "acme-claude" limits = { context_tokens = 1000, max_output_tokens = 500 } capabilities = { text = true, tools = true } - - [providers.acme.models.acme-claude.metadata.fabro] family = "claude" - agent_profile = "anthropic" + + [providers.acme.models.acme-claude.metadata.agent] + profile = "anthropic" "#, )); let mut services = make_services(); diff --git a/lib/components/fabro-workflow/src/model_fallback.rs b/lib/components/fabro-workflow/src/model_fallback.rs index 295d1892a..c4462acb8 100644 --- a/lib/components/fabro-workflow/src/model_fallback.rs +++ b/lib/components/fabro-workflow/src/model_fallback.rs @@ -405,7 +405,7 @@ mod tests { } fn openrouter_catalog() -> Catalog { - test_catalog_with_overlay("[providers.openrouter.metadata.fabro]\nenabled = true\n") + test_catalog_with_overlay("[providers.openrouter]\nenabled = true\n") } #[test] @@ -508,7 +508,7 @@ mod tests { #[test] fn resolves_the_requested_production_policy_as_independent_chains() { let catalog = test_catalog_with_overlay( - "[providers.modal.metadata.fabro]\nenabled = true\n\n[providers.openrouter.metadata.fabro]\nenabled = true\n", + "[providers.modal]\nenabled = true\n\n[providers.openrouter]\nenabled = true\n", ); let eligible = [ ProviderId::new("modal"), diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index 188154f1b..c0cbe93bf 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -746,9 +746,8 @@ mod tests { [providers.openrouter] priority = 25 default_model = "gpt-5.6-sol" - - [providers.openrouter.metadata.fabro] enabled = true + "#, )) } diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index d55e531e3..f5a5c8ad0 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -1454,9 +1454,8 @@ mod tests { [providers.openrouter] priority = 25 default_model = "gpt-5.6-sol" - - [providers.openrouter.metadata.fabro] enabled = true + "#, ) } @@ -1493,9 +1492,8 @@ mod tests { auth = { type = "bearer" } default_model = "acme-claude" - [providers.acme.metadata.fabro] - agent_profile = "openai" - credentials = ["env:ACME_API_KEY"] + [providers.acme.metadata.agent] + profile = "openai" [providers.acme.models.acme-claude] display_name = "Acme Claude" @@ -1503,10 +1501,10 @@ mod tests { api_model = "acme-claude" limits = { context_tokens = 1000, max_output_tokens = 500 } capabilities = { text = true, tools = true } - - [providers.acme.models.acme-claude.metadata.fabro] family = "claude" - agent_profile = "anthropic" + + [providers.acme.models.acme-claude.metadata.agent] + profile = "anthropic" "#, ); let mut settings = ResolvedRunSettings::default(); diff --git a/lib/components/fabro-workflow/src/pipeline/pull_request.rs b/lib/components/fabro-workflow/src/pipeline/pull_request.rs index 74c5cfaa5..f6fb74de5 100644 --- a/lib/components/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/components/fabro-workflow/src/pipeline/pull_request.rs @@ -782,9 +782,8 @@ base_url = "http://mock.invalid/v1" auth = { type = "bearer" } allow_passthrough = true -[providers.mock.metadata.fabro] -agent_profile = "openai" -credentials = ["env:MOCK_API_KEY"] +[providers.mock.metadata.agent] +profile = "openai" [providers.mock.models.mock-model] display_name = "Mock Model" diff --git a/lib/components/fabro-workflow/src/transforms/model_resolution.rs b/lib/components/fabro-workflow/src/transforms/model_resolution.rs index f7450f712..22f1cf33c 100644 --- a/lib/components/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/components/fabro-workflow/src/transforms/model_resolution.rs @@ -174,9 +174,8 @@ auth = { type = "bearer" } priority = 200 default_model = "venice-large" -[providers.acme-venice.metadata.fabro] -agent_profile = "openai" -credentials = ["env:VENICE_API_KEY"] +[providers.acme-venice.metadata.agent] +profile = "openai" [providers.acme-venice.models.venice-large] display_name = "Venice Large" @@ -355,7 +354,7 @@ capabilities = { text = true, tools = true } #[test] fn fallback_resolution_keeps_ready_preference_for_unpinned_nodes() { let catalog = Arc::new(test_catalog_with_overlay( - "[providers.openrouter.metadata.fabro]\nenabled = true\n", + "[providers.openrouter]\nenabled = true\n", )); let mut graph = Graph::new("test"); let mut portable = Node::new("portable"); diff --git a/lib/components/fabro-workflow/tests/it/integration.rs b/lib/components/fabro-workflow/tests/it/integration.rs index c3ab08e63..ee407786e 100644 --- a/lib/components/fabro-workflow/tests/it/integration.rs +++ b/lib/components/fabro-workflow/tests/it/integration.rs @@ -2672,9 +2672,8 @@ base_url = {base_url} auth = {{ type = "bearer" }} default_model = "compact-model" -[providers.compact.metadata.fabro] -agent_profile = "openai" -credentials = ["env:COMPACT_API_KEY"] +[providers.compact.metadata.agent] +profile = "openai" [providers.compact.models.compact-model] display_name = "Compact Model" @@ -2834,8 +2833,6 @@ async fn workflow_persists_authoritative_openrouter_cost_for_agent_stage() { &format!( "[providers.openrouter] base_url = {} - -[providers.openrouter.metadata.fabro] enabled = true ", toml::Value::String(server.base_url()), diff --git a/lib/foundation/fabro-config/src/builders.rs b/lib/foundation/fabro-config/src/builders.rs index b0a949914..db07ae3a3 100644 --- a/lib/foundation/fabro-config/src/builders.rs +++ b/lib/foundation/fabro-config/src/builders.rs @@ -693,10 +693,7 @@ adapter = "openai-compatible" codec = "openai-chat" base_url = "https://api.acme.test/v1" auth = { type = "bearer" } - -[llm.providers.acme.metadata.fabro] enabled = true -credentials = ["env:ACME_API_KEY"] [llm.providers.acme.models."acme-large"] display_name = "Acme Large" @@ -710,7 +707,7 @@ api_model = "acme-large" let overlay = settings.llm_overlay.0; let acme = &overlay["providers"]["acme"]; assert_eq!(acme["display_name"].as_str(), Some("Acme")); - assert_eq!(acme["metadata"]["fabro"]["enabled"].as_bool(), Some(true)); + assert_eq!(acme["enabled"].as_bool(), Some(true)); assert_eq!( acme["models"]["acme-large"]["api_model"].as_str(), Some("acme-large") diff --git a/lib/foundation/fabro-config/src/layers/llm.rs b/lib/foundation/fabro-config/src/layers/llm.rs index 17df191ab..8daa204c8 100644 --- a/lib/foundation/fabro-config/src/layers/llm.rs +++ b/lib/foundation/fabro-config/src/layers/llm.rs @@ -2,17 +2,14 @@ //! //! Operator model catalog overrides. The table uses the lithos-llm catalog //! schema verbatim, minus `schema_version`, and is applied as an overlay layer -//! on top of the lithos built-in catalog and Fabro's policy layer: +//! on top of the lithos built-in catalog: //! //! ```toml -//! [llm.providers.moonshot] +//! [llm.providers.openrouter] //! priority = 60 -//! -//! [llm.providers.moonshot.metadata.fabro] //! enabled = true -//! credentials = ["env:MOONSHOT_API_KEY", "vault:MOONSHOT_API_KEY"] //! -//! [llm.providers.moonshot.models."kimi-k2.5".metadata.fabro] +//! [llm.providers.openrouter.models."kimi-k2.5"] //! small_default = true //! ``` //! @@ -83,7 +80,6 @@ mod tests { r" [providers.acme] priority = 10 -[providers.acme.metadata.fabro] enabled = false ", ); @@ -92,19 +88,19 @@ enabled = false [providers.acme] priority = 5 base_url = "https://acme.test" -[providers.acme.metadata.fabro] enabled = true -small_default = true +[providers.acme.metadata.agent] +profile = "openai" "#, ); let merged = higher.combine(lower).0; let acme = &merged["providers"]["acme"]; assert_eq!(acme["priority"].as_integer(), Some(10)); assert_eq!(acme["base_url"].as_str(), Some("https://acme.test")); - assert_eq!(acme["metadata"]["fabro"]["enabled"].as_bool(), Some(false)); + assert_eq!(acme["enabled"].as_bool(), Some(false)); assert_eq!( - acme["metadata"]["fabro"]["small_default"].as_bool(), - Some(true) + acme["metadata"]["agent"]["profile"].as_str(), + Some("openai") ); } diff --git a/lib/foundation/fabro-config/src/parse.rs b/lib/foundation/fabro-config/src/parse.rs index 8051b1286..e8fd37082 100644 --- a/lib/foundation/fabro-config/src/parse.rs +++ b/lib/foundation/fabro-config/src/parse.rs @@ -295,22 +295,21 @@ mod tests { #[test] fn llm_overlay_merges_across_layers() { let higher = r#" -[llm.providers.openai.models."gpt-5.4".metadata.fabro] +[llm.providers.openai.models."gpt-5.4"] small_default = true "# .parse::() .unwrap(); let lower = r#" -[llm.providers.openai.models."gpt-5.4".metadata.fabro] +[llm.providers.openai.models."gpt-5.4"] probe = true "# .parse::() .unwrap(); let merged = crate::Combine::combine(higher, lower); - let fabro = - &merged.llm.unwrap().0["providers"]["openai"]["models"]["gpt-5.4"]["metadata"]["fabro"]; - assert_eq!(fabro["small_default"].as_bool(), Some(true)); - assert_eq!(fabro["probe"].as_bool(), Some(true)); + let model = &merged.llm.unwrap().0["providers"]["openai"]["models"]["gpt-5.4"]; + assert_eq!(model["small_default"].as_bool(), Some(true)); + assert_eq!(model["probe"].as_bool(), Some(true)); } #[test] diff --git a/lib/foundation/fabro-types/src/catalog_policy.rs b/lib/foundation/fabro-types/src/catalog_policy.rs deleted file mode 100644 index 57f127fb9..000000000 --- a/lib/foundation/fabro-types/src/catalog_policy.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! Fabro's `metadata.fabro` catalog namespace. -//! -//! lithos-llm owns provider and model facts. Fabro attaches its own policy to -//! each entry under `metadata.fabro`, which lithos carries verbatim and never -//! interprets. These types are the typed view of that namespace. Every field -//! is optional in the TOML; the accessors here apply Fabro's defaults. - -use lithos_llm::catalog::{CatalogModel, CatalogProvider}; -use serde::{Deserialize, Serialize}; - -use crate::AgentProfileKind; - -/// Name of the metadata namespace Fabro owns on catalog entries. -pub const FABRO_METADATA_NAMESPACE: &str = "fabro"; - -/// Provider-level Fabro policy. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ProviderPolicy { - /// Whether Fabro offers this provider at all. Missing means enabled. - pub enabled: Option, - /// Default agent profile for models on this provider. - pub agent_profile: Option, - /// Where an operator obtains an API key. - pub api_key_url: Option, - /// Ordered credential references (`env:NAME`, `vault:NAME`, `aws_sigv4`). - /// The first that resolves wins. - pub credentials: Vec, - /// Extra request headers. Values are literal text or `{{ secrets.NAME }}` - /// interpolation strings resolved against the vault. - #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub extra_headers: std::collections::BTreeMap, - /// Another provider this one serves requests for when that provider has - /// no credentials of its own. Used by `openai-codex`, which answers - /// `openai` requests with a ChatGPT OAuth credential. - pub stands_in_for: Option, -} - -impl ProviderPolicy { - #[must_use] - pub fn is_enabled(&self) -> bool { - self.enabled.unwrap_or(true) - } -} - -/// Model-level Fabro policy. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ModelPolicy { - /// Whether Fabro offers this model. Missing means enabled. - pub enabled: Option, - /// Agent profile override for this model. - pub agent_profile: Option, - /// Model family label for display and grouping. - pub family: Option, - /// Training data cutoff label. - pub training: Option, - /// Public knowledge cutoff label. - pub knowledge_cutoff: Option, - /// Estimated output tokens per second. - pub estimated_output_tps: Option, - /// Preferred for small utility calls such as title generation. - pub small_default: bool, - /// Preferred for provider connectivity probes. - pub probe: bool, - /// Whether requests reason when no effort is requested. Missing means - /// "reasons when the model supports reasoning". - pub reasoning_by_default: Option, -} - -impl ModelPolicy { - #[must_use] - pub fn is_enabled(&self) -> bool { - self.enabled.unwrap_or(true) - } -} - -/// Reads a provider's Fabro policy. Malformed metadata falls back to the -/// defaults; the catalog build is the place to validate shape, and Fabro's -/// own policy file is checked in tests. -#[must_use] -pub fn provider_policy(provider: &CatalogProvider) -> ProviderPolicy { - provider - .metadata() - .namespace::(FABRO_METADATA_NAMESPACE) - .ok() - .flatten() - .unwrap_or_default() -} - -/// Reads a model's Fabro policy. -#[must_use] -pub fn model_policy(model: &CatalogModel) -> ModelPolicy { - model - .metadata() - .namespace::(FABRO_METADATA_NAMESPACE) - .ok() - .flatten() - .unwrap_or_default() -} - -/// The agent profile a model runs under: the model override, then the -/// provider default, then the profile implied by the provider's adapter. -#[must_use] -pub fn effective_agent_profile( - provider: &CatalogProvider, - model: &CatalogModel, -) -> AgentProfileKind { - model_policy(model) - .agent_profile - .or(provider_policy(provider).agent_profile) - .unwrap_or_else(|| default_agent_profile(provider)) -} - -/// The agent profile implied by a provider's wire protocol. -#[must_use] -pub fn default_agent_profile(provider: &CatalogProvider) -> AgentProfileKind { - match provider.adapter().as_str() { - "anthropic" | "bedrock" => AgentProfileKind::Anthropic, - "gemini" => AgentProfileKind::Gemini, - _ => AgentProfileKind::OpenAi, - } -} - -#[cfg(test)] -mod tests { - use lithos_llm::catalog::Catalog; - - use super::*; - - fn catalog() -> Catalog { - Catalog::builder() - .toml_layer( - "test", - r#" -schema_version = 1 - -[providers.acme] -display_name = "Acme" -adapter = "openai-compatible" -codec = "openai-chat" -base_url = "https://acme.test/v1" -auth = { type = "bearer" } -default_model = "large" - -[providers.acme.metadata.fabro] -enabled = false -credentials = ["env:ACME_API_KEY"] -agent_profile = "kimi" - -[providers.acme.models.large] -display_name = "Large" -api_model = "large" - -[providers.acme.models.large.metadata.fabro] -small_default = true -probe = true -family = "acme" - -[providers.acme.models.small] -display_name = "Small" -api_model = "small" -[providers.acme.models.small.metadata.fabro] -agent_profile = "openai" -enabled = false -"#, - ) - .unwrap() - .build() - .unwrap() - } - - #[test] - fn reads_provider_and_model_policy() { - let catalog = catalog(); - let provider = catalog.provider("acme").unwrap(); - let policy = provider_policy(provider); - assert!(!policy.is_enabled()); - assert_eq!(policy.credentials, vec!["env:ACME_API_KEY"]); - assert_eq!(policy.agent_profile, Some(AgentProfileKind::Kimi)); - - let large = provider.model("large").unwrap(); - let policy = model_policy(large); - assert!(policy.small_default && policy.probe && policy.is_enabled()); - assert_eq!(policy.family.as_deref(), Some("acme")); - assert_eq!( - effective_agent_profile(provider, large), - AgentProfileKind::Kimi - ); - - let small = provider.model("small").unwrap(); - assert!(!model_policy(small).is_enabled()); - assert_eq!( - effective_agent_profile(provider, small), - AgentProfileKind::OpenAi - ); - } - - #[test] - fn missing_namespace_yields_defaults() { - let catalog = Catalog::builder() - .toml_layer( - "test", - r#" -schema_version = 1 -[providers.bare] -display_name = "Bare" -adapter = "anthropic" -codec = "anthropic-messages" -base_url = "https://bare.test" -auth = { type = "none" } -[providers.bare.models.m] -display_name = "M" -api_model = "m" -"#, - ) - .unwrap() - .build() - .unwrap(); - let provider = catalog.provider("bare").unwrap(); - assert!(provider_policy(provider).is_enabled()); - let model = provider.model("m").unwrap(); - assert!(model_policy(model).is_enabled()); - assert_eq!( - effective_agent_profile(provider, model), - AgentProfileKind::Anthropic - ); - } -} diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index 60fd05c9e..24a51c0a0 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -8,7 +8,6 @@ pub mod billing_rollup; pub mod blob_hash; pub mod blob_ref; pub mod catalog_api; -pub mod catalog_policy; pub mod checkpoint; pub mod command_output; pub mod conclusion; @@ -75,7 +74,6 @@ pub use billing::{ pub use blob_hash::BlobHash; pub use blob_ref::{format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref}; pub use catalog_api::{Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, Provider}; -pub use catalog_policy::{ModelPolicy, ProviderPolicy}; pub use checkpoint::Checkpoint; pub use command_output::{CommandOutputStream, CommandTermination}; pub use conclusion::{Conclusion, StageSummary};