mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
Read catalog policy from lithos core fields and metadata.agent
Fabro's policy layer restated the lithos built-ins under `metadata.fabro`: enabled flags, credentials, display facts, probe and small-default roles, and agent profiles. lithos-llm now carries every one of those as a core field or under the shared `metadata.agent` namespace, so the layer and its typed view go: - Delete `fabro-policy.toml` and `FABRO_POLICY_TOML`. The catalog is the lithos built-ins plus the operator's `[llm]` overlay, nothing between. - Delete `fabro_types::catalog_policy`. `enabled`, `stands_in_for`, `api_key_url`, `family`, the cutoffs, `estimated_output_tps`, `small_default`, and `probe` are read from lithos accessors; the agent profile and `reasoning_by_default` come from `metadata.agent`, which Pebble reads too. - `catalog::provider`, `enabled_providers`, and `listed_providers` return the lithos `CatalogProvider` directly; `ModelEntry` loses its policy field and gains `agent_profile()`. - Test fixtures move `[providers.x.metadata.fabro] enabled = true` onto the provider table, drop `credentials` lists in favor of the secret name lithos derives from the provider id, and spell `agent_profile` as `metadata.agent.profile`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
6dfc96d3fd
commit
2a2fc41807
36 changed files with 314 additions and 1445 deletions
|
|
@ -83,22 +83,22 @@ fn supports_install_api_key(provider: &CatalogProvider) -> bool {
|
|||
|
||||
fn install_llm_provider_ids(catalog: &Catalog) -> Vec<ProviderId> {
|
||||
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()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ fn default_catalog_for_provider_auth() -> Arc<Catalog> {
|
|||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -909,13 +909,13 @@ fn api_model_on_eligible(
|
|||
eligible: &std::collections::HashSet<ProviderId>,
|
||||
) -> 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
|
||||
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")])
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ pub trait AgentProfile: Send + Sync {
|
|||
|
||||
fn knowledge_cutoff(&self) -> Option<String> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ mod tests {
|
|||
/// OpenRouter ships disabled in the built-in catalog.
|
||||
fn catalog_with_openrouter() -> Arc<Catalog> {
|
||||
Arc::new(test_catalog_with_overlay(
|
||||
"[providers.openrouter.metadata.fabro]
|
||||
"[providers.openrouter]
|
||||
enabled = true
|
||||
",
|
||||
))
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ mod tests {
|
|||
/// selectable. Enable it the way they would, to observe gateway routing.
|
||||
fn catalog_with_openrouter() -> Arc<Catalog> {
|
||||
Arc::new(test_catalog_with_overlay(
|
||||
"[providers.openrouter.metadata.fabro]
|
||||
"[providers.openrouter]
|
||||
enabled = true
|
||||
",
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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<ProviderId>) -> Vec<Model>
|
|||
pub fn providers(catalog: &Catalog, configured: &HashSet<ProviderId>) -> Vec<Provider> {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
) -> Result<Catalog, CatalogError> {
|
||||
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<AgentProfileKind>,
|
||||
reasoning_by_default: Option<bool>,
|
||||
}
|
||||
|
||||
fn agent_metadata(metadata: &Metadata) -> AgentMetadata {
|
||||
metadata
|
||||
.namespace::<AgentMetadata>(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<ProviderEntry<'_>> {
|
||||
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<ProviderEntry<'_>> {
|
|||
/// 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<ProviderEntry<'_>> {
|
||||
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<ProviderEntry<'_>> {
|
|||
pub fn enabled_provider_ids(catalog: &Catalog) -> HashSet<ProviderId> {
|
||||
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<ProviderEntry<'a>> {
|
||||
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<ProviderId> {
|
||||
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<ModelEntry<'_>> {
|
||||
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<ModelEntry<'_>> {
|
||||
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<ModelEntry<'a>> {
|
||||
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<ModelEntry<'a>> {
|
||||
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<ModelEnt
|
|||
matches
|
||||
}
|
||||
|
||||
/// Whether `selector` names an enabled model on any enabled provider.
|
||||
/// Whether `selector` names a model on any enabled provider.
|
||||
#[must_use]
|
||||
pub fn is_model_selector(catalog: &Catalog, selector: &str) -> 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<ModelEntry<'a>> {
|
||||
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<ModelEntry<'a>> {
|
||||
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<ProviderId>,
|
||||
) -> Option<ModelEntry<'a>> {
|
||||
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<AgentProfileKind> {
|
||||
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<ProviderId, Vec<ModelId>> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -104,10 +104,10 @@ pub async fn probe_provider_with_api_key(
|
|||
api_key: String,
|
||||
timeout: Duration,
|
||||
) -> Result<ModelTestOutcome, ApiKeyProbeError> {
|
||||
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())
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ pub(super) fn check_provider_known(
|
|||
}
|
||||
let valid: Vec<String> = 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 {
|
||||
|
|
|
|||
|
|
@ -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<Catalog> {
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -746,9 +746,8 @@ mod tests {
|
|||
[providers.openrouter]
|
||||
priority = 25
|
||||
default_model = "gpt-5.6-sol"
|
||||
|
||||
[providers.openrouter.metadata.fabro]
|
||||
enabled = true
|
||||
|
||||
"#,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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::<SettingsLayer>()
|
||||
.unwrap();
|
||||
let lower = r#"
|
||||
[llm.providers.openai.models."gpt-5.4".metadata.fabro]
|
||||
[llm.providers.openai.models."gpt-5.4"]
|
||||
probe = true
|
||||
"#
|
||||
.parse::<SettingsLayer>()
|
||||
.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]
|
||||
|
|
|
|||
|
|
@ -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<bool>,
|
||||
/// Default agent profile for models on this provider.
|
||||
pub agent_profile: Option<AgentProfileKind>,
|
||||
/// Where an operator obtains an API key.
|
||||
pub api_key_url: Option<String>,
|
||||
/// Ordered credential references (`env:NAME`, `vault:NAME`, `aws_sigv4`).
|
||||
/// The first that resolves wins.
|
||||
pub credentials: Vec<String>,
|
||||
/// 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<String, String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<bool>,
|
||||
/// Agent profile override for this model.
|
||||
pub agent_profile: Option<AgentProfileKind>,
|
||||
/// Model family label for display and grouping.
|
||||
pub family: Option<String>,
|
||||
/// Training data cutoff label.
|
||||
pub training: Option<String>,
|
||||
/// Public knowledge cutoff label.
|
||||
pub knowledge_cutoff: Option<String>,
|
||||
/// Estimated output tokens per second.
|
||||
pub estimated_output_tps: Option<f64>,
|
||||
/// 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<bool>,
|
||||
}
|
||||
|
||||
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::<ProviderPolicy>(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::<ModelPolicy>(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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue